diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b25c18c1c1d..15754b83a33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,19 +138,6 @@ jobs: - - name: Publish docs - env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - run: | - - eval "$(ssh-agent -s)" - echo "$SSH_PRIVATE_KEY" | ssh-add - - git config --global user.name "GitHub Actions CI" - git config --global user.email "ghactions@invalid" - sbt ++2.12.15 docs/makeSite docs/ghpagesPushSite - - - website: name: Build website strategy: diff --git a/NOTICE b/NOTICE index cf7c6103761..12a2e2edfbc 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,5 @@ http4s -Copyright 2013-2018 http4s.org +Copyright 2013-2021 http4s.org Licensed under Apache License 2.0 (see LICENSE) This software contains portions of code derived from akka-http diff --git a/README.md b/README.md index aeeedd7820f..9b5c0114560 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Http4s [![Build Status](https://travis-ci.org/http4s/http4s.svg?branch=master)](https://travis-ci.org/http4s/http4s) [![Gitter chat](https://badges.gitter.im/http4s/http4s.png)](https://gitter.im/http4s/http4s) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.http4s/http4s-core_2.12/badge.svg)](https://maven-badges.herokuapp.com/maven-central/org.http4s/http4s-core_2.12) [![Typelevel library](https://img.shields.io/badge/typelevel-library-green.svg)](https://typelevel.org/projects/#http4s) Cats friendly +# Http4s [![Build Status](https://github.com/http4s/http4s/workflows/Continuous%20Integration/badge.svg?branch=main)](https://github.com/http4s/http4s/actions?query=branch%3Amain+workflow%3A%22Continuous+Integration%22) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/org.http4s/http4s-core_2.12/badge.svg)](https://maven-badges.herokuapp.com/maven-central/org.http4s/http4s-core_2.12) [![Typelevel library](https://img.shields.io/badge/typelevel-library-green.svg)](https://typelevel.org/projects/#http4s) Cats friendly Http4s is a minimal, idiomatic Scala interface for HTTP services. Http4s is Scala's answer to Ruby's Rack, Python's WSGI, Haskell's WAI, and Java's diff --git a/SECURITY.md b/SECURITY.md index 4c13c7f693d..47d378739d7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,11 +6,14 @@ We are currently providing security updates to the following versions: | Version | Supported | | ------- | ------------------ | -| 0.21.x | :white_check_mark: | -| 0.20.x | :white_check_mark: | -| 0.19.x | :x: | -| 0.18.x | :x: | -| < 0.18 | :x: | +| 1.0.x | :white_check_mark: | +| 0.23.x | :white_check_mark: | +| 0.22.x | :white_check_mark: | +| 0.21.x | :white_check_mark: | +| 0.20.x | :x: | +| 0.19.x | :x: | +| 0.18.x | :x: | +| < 0.18 | :x: | ## Reporting a Vulnerability diff --git a/async-http-client/src/main/scala/org/http4s/asynchttpclient/AsyncHttpClient.scala b/async-http-client/src/main/scala/org/http4s/asynchttpclient/AsyncHttpClient.scala index f7a0d21691b..362be0723e2 100644 --- a/async-http-client/src/main/scala/org/http4s/asynchttpclient/AsyncHttpClient.scala +++ b/async-http-client/src/main/scala/org/http4s/asynchttpclient/AsyncHttpClient.scala @@ -19,9 +19,9 @@ package asynchttpclient package client import cats.effect._ -import cats.effect.concurrent._ +import cats.effect.kernel.{Async, Resource} +import cats.effect.std.Dispatcher import cats.syntax.all._ -import cats.effect.implicits._ import fs2.Stream._ import fs2._ import fs2.interop.reactivestreams.{StreamSubscriber, StreamUnicastPublisher} @@ -48,28 +48,38 @@ object AsyncHttpClient { .setCookieStore(new NoOpCookieStore) .build() - def apply[F[_]](httpClient: AsyncHttpClient)(implicit F: ConcurrentEffect[F]): Client[F] = - Client[F] { req => - Resource(F.async[(Response[F], F[Unit])] { cb => - httpClient.executeRequest(toAsyncRequest(req), asyncHandler(cb)) - () - }) + /** Create a HTTP client with an existing AsyncHttpClient client. The supplied client is NOT + * closed by this Resource! + */ + def fromClient[F[_]](httpClient: AsyncHttpClient)(implicit F: Async[F]): Resource[F, Client[F]] = + Dispatcher[F].flatMap { dispatcher => + val client = Client[F] { req => + Resource(F.async[(Response[F], F[Unit])] { cb => + F.delay(httpClient + .executeRequest(toAsyncRequest(req, dispatcher), asyncHandler(cb, dispatcher))) + .as(None) + }) + } + + Resource.eval(F.pure(client)) } /** Allocates a Client and its shutdown mechanism for freeing resources. */ def allocate[F[_]](config: AsyncHttpClientConfig = defaultConfig)(implicit - F: ConcurrentEffect[F]): F[(Client[F], F[Unit])] = - F.delay(new DefaultAsyncHttpClient(config)) - .map(c => (apply(c), F.delay(c.close()))) + F: Async[F]): F[(Client[F], F[Unit])] = + resource(config).allocated /** Create an HTTP client based on the AsyncHttpClient library * * @param config configuration for the client */ def resource[F[_]](config: AsyncHttpClientConfig = defaultConfig)(implicit - F: ConcurrentEffect[F]): Resource[F, Client[F]] = - Resource(allocate(config)) + F: Async[F]): Resource[F, Client[F]] = + Resource.make(F.delay(new DefaultAsyncHttpClient(config)))(c => F.delay(c.close())).flatMap { + httpClient => + fromClient(httpClient) + } /** Create a bracketed HTTP client based on the AsyncHttpClient library. * @@ -78,7 +88,7 @@ object AsyncHttpClient { * shutdown when the stream terminates. */ def stream[F[_]](config: AsyncHttpClientConfig = defaultConfig)(implicit - F: ConcurrentEffect[F]): Stream[F, Client[F]] = + F: Async[F]): Stream[F, Client[F]] = Stream.resource(resource(config)) /** Create a custom AsyncHttpClientConfig @@ -93,8 +103,8 @@ object AsyncHttpClient { configurationFn(defaultConfigBuilder).build() } - private def asyncHandler[F[_]](cb: Callback[(Response[F], F[Unit])])(implicit - F: ConcurrentEffect[F]) = + private def asyncHandler[F[_]](cb: Callback[(Response[F], F[Unit])], dispatcher: Dispatcher[F])( + implicit F: Async[F]) = new StreamedAsyncHandler[Unit] { var state: State = State.CONTINUE var response: Response[F] = Response() @@ -106,7 +116,7 @@ object AsyncHttpClient { val eff = for { _ <- onStreamCalled.set(true) - subscriber <- StreamSubscriber[F, HttpResponseBodyPart] + subscriber <- StreamSubscriber[F, HttpResponseBodyPart](dispatcher) subscribeF = F.delay(publisher.subscribe(subscriber)) @@ -117,7 +127,7 @@ object AsyncHttpClient { body = subscriber .stream(bodyDisposal.set(F.unit) >> subscribeF) - .flatMap(part => chunk(Chunk.bytes(part.getBodyPartBytes))) + .flatMap(part => chunk(Chunk.array(part.getBodyPartBytes))) .mergeHaltBoth(Stream.eval(deferredThrowable.get.flatMap(F.raiseError[Byte]))) responseWithBody = response.copy(body = body) @@ -126,7 +136,7 @@ object AsyncHttpClient { invokeCallbackF[F](cb(Right(responseWithBody -> (dispose >> bodyDisposal.get.flatten)))) } yield () - eff.runAsync(_ => IO.unit).unsafeRunSync() + dispatcher.unsafeRunSync(eff) state } @@ -144,39 +154,46 @@ object AsyncHttpClient { state } - override def onThrowable(throwable: Throwable): Unit = - onStreamCalled.get + override def onThrowable(throwable: Throwable): Unit = { + val fa = onStreamCalled.get .ifM( - ifTrue = deferredThrowable.complete(throwable), + ifTrue = deferredThrowable.complete(throwable).void, ifFalse = invokeCallbackF(cb(Left(throwable)))) - .runAsync(_ => IO.unit) - .unsafeRunSync() - override def onCompleted(): Unit = - onStreamCalled.get + dispatcher.unsafeRunSync(fa) + } + + override def onCompleted(): Unit = { + val fa = onStreamCalled.get .ifM(ifTrue = F.unit, ifFalse = invokeCallbackF[F](cb(Right(response -> dispose)))) - .runAsync(_ => IO.unit) - .unsafeRunSync() + + dispatcher.unsafeRunSync(fa) + } } // use fibers to access the ContextShift and ensure that we get off of the AHC thread pool - private def invokeCallbackF[F[_]](invoked: => Unit)(implicit F: Concurrent[F]): F[Unit] = - F.start(F.delay(invoked)).flatMap(_.join) + private def invokeCallbackF[F[_]](invoked: => Unit)(implicit F: Async[F]): F[Unit] = + F.start(F.delay(invoked)).flatMap(_.joinWithNever) - private def toAsyncRequest[F[_]: ConcurrentEffect](request: Request[F]): AsyncRequest = { + private def toAsyncRequest[F[_]: Async]( + request: Request[F], + dispatcher: Dispatcher[F]): AsyncRequest = { val headers = new DefaultHttpHeaders for (h <- request.headers.headers) headers.add(h.name.toString, h.value) new RequestBuilder(request.method.renderString) .setUrl(request.uri.renderString) .setHeaders(headers) - .setBody(getBodyGenerator(request)) + .setBody(getBodyGenerator(request, dispatcher)) .build() } - private def getBodyGenerator[F[_]: ConcurrentEffect](req: Request[F]): BodyGenerator = { + private def getBodyGenerator[F[_]: Async]( + req: Request[F], + dispatcher: Dispatcher[F]): BodyGenerator = { val publisher = StreamUnicastPublisher( - req.body.chunks.map(chunk => Unpooled.wrappedBuffer(chunk.toArray))) + req.body.chunks.map(chunk => Unpooled.wrappedBuffer(chunk.toArray)), + dispatcher) if (req.isChunked) new ReactiveStreamsBodyGenerator(publisher, -1) else req.contentLength match { diff --git a/async-http-client/src/test/scala/org/http4s/asynchttpclient/AsyncHttpClientSuite.scala b/async-http-client/src/test/scala/org/http4s/asynchttpclient/AsyncHttpClientSuite.scala index 94032f28cbf..f1f2bff6fc2 100644 --- a/async-http-client/src/test/scala/org/http4s/asynchttpclient/AsyncHttpClientSuite.scala +++ b/async-http-client/src/test/scala/org/http4s/asynchttpclient/AsyncHttpClientSuite.scala @@ -23,7 +23,7 @@ import org.asynchttpclient.DefaultAsyncHttpClient import org.asynchttpclient.HostStats import org.http4s.client.{Client, ClientRouteTestBattery, DefaultClient, defaults} -class AsyncHttpClientSpec extends ClientRouteTestBattery("AsyncHttpClient") with Http4sSuite { +class AsyncHttpClientSuite extends ClientRouteTestBattery("AsyncHttpClient") with Http4sSuite { def clientResource: Resource[IO, Client[IO]] = AsyncHttpClient.resource[IO]() @@ -76,15 +76,13 @@ class AsyncHttpClientSpec extends ClientRouteTestBattery("AsyncHttpClient") with } test("AsyncHttpClientStats should correctly get the stats from the underlying ClientStats") { - - val clientWithStats: Resource[IO, Client[IO]] = Resource( - IO.delay(new DefaultAsyncHttpClient(AsyncHttpClient.defaultConfig)) - .map(c => - ( - new ClientWithStats( - AsyncHttpClient.apply(c), - new AsyncHttpClientStats[IO](c.getClientStats)), - IO.delay(c.close())))) + val clientWithStats: Resource[IO, Client[IO]] = + for { + httpClient <- Resource.make( + IO.delay(new DefaultAsyncHttpClient(AsyncHttpClient.defaultConfig)))(client => + IO(client.close())) + client <- AsyncHttpClient.fromClient[IO](httpClient) + } yield new ClientWithStats(client, new AsyncHttpClientStats[IO](httpClient.getClientStats)) val clientStats: Resource[IO, AsyncHttpClientStats[IO]] = clientWithStats.map { case client: ClientWithStats => client.getStats diff --git a/bench/src/main/scala/org/http4s/bench/CirceJsonBench.scala b/bench/src/main/scala/org/http4s/bench/CirceJsonBench.scala index 19e579a5975..13a4ea63610 100644 --- a/bench/src/main/scala/org/http4s/bench/CirceJsonBench.scala +++ b/bench/src/main/scala/org/http4s/bench/CirceJsonBench.scala @@ -20,6 +20,7 @@ package bench import java.util.concurrent.TimeUnit import cats.effect.IO +import cats.effect.unsafe.implicits.global import io.circe._ import io.circe.parser._ import org.http4s.circe._ diff --git a/bench/src/main/scala/org/http4s/ember/bench/EmberParserBench.scala b/bench/src/main/scala/org/http4s/ember/bench/EmberParserBench.scala index 38d61d5f0b5..ea96791c2d5 100644 --- a/bench/src/main/scala/org/http4s/ember/bench/EmberParserBench.scala +++ b/bench/src/main/scala/org/http4s/ember/bench/EmberParserBench.scala @@ -20,8 +20,8 @@ import java.util.concurrent.TimeUnit import org.http4s._ import cats.effect.IO +import cats.effect.unsafe.implicits.global import org.openjdk.jmh.annotations._ -import cats.effect.ContextShift import org.http4s.ember.core.Parser // sbt "bench/Jmh/run -i 5 -wi 5 -f 1 -t 1 org.http4s.ember.bench.EmberParserBench" @@ -54,8 +54,6 @@ class EmberParserBench { object EmberParserBench { - implicit val CS: ContextShift[IO] = IO.contextShift(scala.concurrent.ExecutionContext.global) - @State(Scope.Benchmark) class BenchState { val maxHeaderSize = 256 * 1024 diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClient.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClient.scala index 3a193622ded..88a8fe74f82 100644 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClient.scala +++ b/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClient.scala @@ -18,12 +18,11 @@ package org.http4s package blaze package client -import cats.effect._ -import cats.effect.concurrent._ +import cats.effect.kernel.{Async, Deferred, Resource} import cats.effect.implicits._ import cats.syntax.all._ import java.nio.ByteBuffer -import java.util.concurrent.TimeoutException +import java.util.concurrent.{CancellationException, TimeoutException} import org.http4s.blaze.util.TickWheelExecutor import org.http4s.blazecore.ResponseHeaderTimeoutStage import org.http4s.client.{Client, RequestKey} @@ -33,27 +32,9 @@ import scala.concurrent.duration._ /** Blaze client implementation */ object BlazeClient { - private[this] val logger = getLogger + import Resource.ExitCase - /** Construct a new [[Client]] using blaze components - * - * @param manager source for acquiring and releasing connections. Not owned by the returned client. - * @param config blaze client configuration. - * @param onShutdown arbitrary tasks that will be executed when this client is shutdown - */ - @deprecated("Use BlazeClientBuilder", "0.19.0-M2") - def apply[F[_], A <: BlazeConnection[F]]( - manager: ConnectionManager[F, A], - config: BlazeClientConfig, - onShutdown: F[Unit], - ec: ExecutionContext)(implicit F: ConcurrentEffect[F]): Client[F] = - makeClient( - manager, - responseHeaderTimeout = config.responseHeaderTimeout, - requestTimeout = config.requestTimeout, - scheduler = bits.ClientTickWheel, - ec = ec - ) + private[this] val logger = getLogger private[blaze] def makeClient[F[_], A <: BlazeConnection[F]]( manager: ConnectionManager[F, A], @@ -61,7 +42,7 @@ object BlazeClient { requestTimeout: Duration, scheduler: TickWheelExecutor, ec: ExecutionContext - )(implicit F: ConcurrentEffect[F]) = + )(implicit F: Async[F]) = Client[F] { req => Resource.suspend { val key = RequestKey.fromRequest(req) @@ -75,9 +56,9 @@ object BlazeClient { def borrow: Resource[F, manager.NextConnection] = Resource.makeCase(manager.borrow(key)) { - case (_, ExitCase.Completed) => + case (_, ExitCase.Succeeded) => F.unit - case (next, ExitCase.Error(_) | ExitCase.Canceled) => + case (next, ExitCase.Errored(_) | ExitCase.Canceled) => invalidate(next.connection) } @@ -103,14 +84,21 @@ object BlazeClient { next.connection.spliceBefore(stage) stage }.bracket(stage => - F.asyncF[TimeoutException] { cb => - F.delay(stage.init(cb)) >> gate.complete(()) + F.async[TimeoutException] { cb => + F.delay(stage.init(cb)) >> gate.complete(()).as(None) })(stage => F.delay(stage.removeStage())) F.racePair(gate.get *> res, responseHeaderTimeoutF) .flatMap[Resource[F, Response[F]]] { - case Left((r, fiber)) => fiber.cancel.as(r) - case Right((fiber, t)) => fiber.cancel >> F.raiseError(t) + case Left((outcome, fiber)) => + fiber.cancel >> outcome.embed( + F.raiseError(new CancellationException("Response canceled"))) + case Right((fiber, outcome)) => + fiber.cancel >> outcome.fold( + F.raiseError(new TimeoutException("Response timeout also timed out")), + F.raiseError, + _.flatMap(F.raiseError) + ) } } case _ => res @@ -120,22 +108,24 @@ object BlazeClient { val res = loop requestTimeout match { case d: FiniteDuration => - F.racePair( + F.race( res, - F.cancelable[TimeoutException] { cb => - val c = scheduler.schedule( - new Runnable { - def run() = - cb(Right( - new TimeoutException(s"Request to $key timed out after ${d.toMillis} ms"))) - }, - ec, - d) - F.delay(c.cancel()) + F.async[TimeoutException] { cb => + F.delay { + scheduler.schedule( + new Runnable { + def run() = + cb(Right(new TimeoutException( + s"Request to $key timed out after ${d.toMillis} ms"))) + }, + ec, + d + ) + }.map(c => Some(F.delay(c.cancel()))) } ).flatMap[Resource[F, Response[F]]] { - case Left((r, fiber)) => fiber.cancel.as(r) - case Right((fiber, t)) => fiber.cancel >> F.raiseError(t) + case Left(r) => F.pure(r) + case Right(t) => F.raiseError(t) } case _ => res diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientBuilder.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientBuilder.scala index 8b7ffdeb16c..1f171ee6410 100644 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientBuilder.scala +++ b/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientBuilder.scala @@ -18,8 +18,9 @@ package org.http4s package blaze package client -import cats.effect._ import cats.syntax.all._ +import cats.effect.kernel.{Async, Resource} +import cats.effect.std.Dispatcher import java.net.InetSocketAddress import java.nio.channels.AsynchronousChannelGroup import javax.net.ssl.SSLContext @@ -78,7 +79,7 @@ sealed abstract class BlazeClientBuilder[F[_]] private ( val asynchronousChannelGroup: Option[AsynchronousChannelGroup], val channelOptions: ChannelOptions, val customDnsResolver: Option[RequestKey => Either[Throwable, InetSocketAddress]] -)(implicit protected val F: ConcurrentEffect[F]) +)(implicit protected val F: Async[F]) extends BlazeBackendBuilder[Client[F]] with BackendBuilder[F, Client[F]] { type Self = BlazeClientBuilder[F] @@ -236,10 +237,11 @@ sealed abstract class BlazeClientBuilder[F[_]] private ( */ def resourceWithState: Resource[F, (Client[F], BlazeClientState[F])] = for { + dispatcher <- Dispatcher[F] scheduler <- scheduler _ <- Resource.eval(verifyAllTimeoutsAccuracy(scheduler)) _ <- Resource.eval(verifyTimeoutRelations()) - manager <- connectionManager(scheduler) + manager <- connectionManager(scheduler, dispatcher) client = BlazeClient.makeClient( manager = manager, responseHeaderTimeout = responseHeaderTimeout, @@ -288,8 +290,8 @@ sealed abstract class BlazeClientBuilder[F[_]] private ( logger.warn(s"requestTimeout ($requestTimeout) is >= idleTimeout ($idleTimeout). $advice") } - private def connectionManager(scheduler: TickWheelExecutor)(implicit - F: ConcurrentEffect[F]): Resource[F, ConnectionManager.Stateful[F, BlazeConnection[F]]] = { + private def connectionManager(scheduler: TickWheelExecutor, dispatcher: Dispatcher[F])(implicit + F: Async[F]): Resource[F, ConnectionManager.Stateful[F, BlazeConnection[F]]] = { val http1: ConnectionBuilder[F, BlazeConnection[F]] = new Http1Support( sslContextOption = sslContext, bufferSize = bufferSize, @@ -305,6 +307,7 @@ sealed abstract class BlazeClientBuilder[F[_]] private ( userAgent = userAgent, channelOptions = channelOptions, connectTimeout = connectTimeout, + dispatcher = dispatcher, idleTimeout = idleTimeout, getAddress = customDnsResolver.getOrElse(BlazeClientBuilder.getAddress(_)) ).makeClient @@ -327,7 +330,7 @@ object BlazeClientBuilder { * * @param executionContext the ExecutionContext for blaze's internal Futures. Most clients should pass scala.concurrent.ExecutionContext.global */ - def apply[F[_]: ConcurrentEffect](executionContext: ExecutionContext): BlazeClientBuilder[F] = + def apply[F[_]: Async](executionContext: ExecutionContext): BlazeClientBuilder[F] = new BlazeClientBuilder[F]( responseHeaderTimeout = Duration.Inf, idleTimeout = 1.minute, diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientConfig.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientConfig.scala deleted file mode 100644 index 3dd9382d364..00000000000 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientConfig.scala +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2014 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.blaze -package client - -import java.nio.channels.AsynchronousChannelGroup -import javax.net.ssl.SSLContext -import org.http4s.client.RequestKey -import org.http4s.headers.`User-Agent` -import scala.concurrent.ExecutionContext -import scala.concurrent.duration._ - -/** Config object for the blaze clients - * - * @param responseHeaderTimeout duration between the submission of a - * request and the completion of the response header. Does not - * include time to read the response body. - * @param idleTimeout duration that a connection can wait without - * traffic being read or written before timeout - * @param requestTimeout maximum duration from the submission of a - * request through reading the body before a timeout. - * @param userAgent optional custom user agent header - * @param maxTotalConnections maximum connections the client will have at any specific time - * @param maxWaitQueueLimit maximum number requests waiting for a connection at any specific time - * @param maxConnectionsPerRequestKey Map of RequestKey to number of max connections - * @param sslContext optional custom `SSLContext` to use to replace - * the default, `SSLContext.getDefault`. - * @param checkEndpointIdentification require endpoint identification - * for secure requests according to RFC 2818, Section 3.1. If the - * certificate presented does not match the hostname of the request, - * the request fails with a CertificateException. This setting does - * not affect checking the validity of the cert via the - * `sslContext`'s trust managers. - * @param maxResponseLineSize maximum length of the request line - * @param maxHeaderLength maximum length of headers - * @param maxChunkSize maximum size of chunked content chunks - * @param chunkBufferMaxSize Size of the buffer that is used when Content-Length header is not specified. - * @param lenientParser a lenient parser will accept illegal chars but replaces them with � (0xFFFD) - * @param bufferSize internal buffer size of the blaze client - * @param executionContext custom executionContext to run async computations. - * @param group custom `AsynchronousChannelGroup` to use other than the system default - */ -@deprecated("Use BlazeClientBuilder", "0.19.0-M2") -final case class BlazeClientConfig( // HTTP properties - responseHeaderTimeout: Duration, - idleTimeout: Duration, - requestTimeout: Duration, - userAgent: Option[`User-Agent`], - // pool options - maxTotalConnections: Int, - maxWaitQueueLimit: Int, - maxConnectionsPerRequestKey: RequestKey => Int, - // security options - sslContext: Option[SSLContext], - @deprecatedName(Symbol("endpointAuthentication")) checkEndpointIdentification: Boolean, - // parser options - maxResponseLineSize: Int, - maxHeaderLength: Int, - maxChunkSize: Int, - chunkBufferMaxSize: Int, - lenientParser: Boolean, - // pipeline management - bufferSize: Int, - executionContext: ExecutionContext, - group: Option[AsynchronousChannelGroup]) { - @deprecated("Parameter has been renamed to `checkEndpointIdentification`", "0.16") - def endpointAuthentication: Boolean = checkEndpointIdentification -} - -@deprecated("Use BlazeClientBuilder", "0.19.0-M2") -object BlazeClientConfig { - - /** Default configuration of a blaze client. */ - val defaultConfig = - BlazeClientConfig( - responseHeaderTimeout = bits.DefaultResponseHeaderTimeout, - idleTimeout = bits.DefaultTimeout, - requestTimeout = 1.minute, - userAgent = bits.DefaultUserAgent, - maxTotalConnections = bits.DefaultMaxTotalConnections, - maxWaitQueueLimit = bits.DefaultMaxWaitQueueLimit, - maxConnectionsPerRequestKey = _ => bits.DefaultMaxTotalConnections, - sslContext = None, - checkEndpointIdentification = true, - maxResponseLineSize = 4 * 1024, - maxHeaderLength = 40 * 1024, - maxChunkSize = Integer.MAX_VALUE, - chunkBufferMaxSize = 1024 * 1024, - lenientParser = false, - bufferSize = bits.DefaultBufferSize, - executionContext = ExecutionContext.global, - group = None - ) - - /** Creates an SSLContext that trusts all certificates and disables - * endpoint identification. This is convenient in some development - * environments for testing with untrusted certificates, but is - * not recommended for production use. - */ - val insecure: BlazeClientConfig = - defaultConfig.copy( - sslContext = Some(bits.TrustingSslContext), - checkEndpointIdentification = false) -} diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/ConnectionManager.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/ConnectionManager.scala index f2def2a0cfd..21419da0398 100644 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/ConnectionManager.scala +++ b/blaze-client/src/main/scala/org/http4s/blaze/client/ConnectionManager.scala @@ -19,7 +19,7 @@ package blaze package client import cats.effect._ -import cats.effect.concurrent.Semaphore +import cats.effect.std.Semaphore import cats.syntax.all._ import org.http4s.client.{Connection, ConnectionBuilder, RequestKey} import scala.concurrent.ExecutionContext @@ -76,7 +76,7 @@ private object ConnectionManager { * @param maxConnectionsPerRequestKey Map of RequestKey to number of max connections * @param executionContext `ExecutionContext` where async operations will execute */ - def pool[F[_]: Concurrent, A <: Connection[F]]( + def pool[F[_]: Async, A <: Connection[F]]( builder: ConnectionBuilder[F, A], maxTotal: Int, maxWaitQueueLimit: Int, @@ -84,7 +84,7 @@ private object ConnectionManager { responseHeaderTimeout: Duration, requestTimeout: Duration, executionContext: ExecutionContext): F[ConnectionManager.Stateful[F, A]] = - Semaphore.uncancelable(1).map { semaphore => + Semaphore(1).map { semaphore => new PoolManager[F, A]( builder, maxTotal, diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Client.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Client.scala deleted file mode 100644 index de4415deb65..00000000000 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Client.scala +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2014 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 blaze -package client - -import cats.effect._ -import fs2.Stream -import org.http4s.blaze.channel.ChannelOptions -import org.http4s.client.{Client, ConnectionBuilder} -import org.http4s.internal.SSLContextOption -import scala.concurrent.duration.Duration - -/** Create a HTTP1 client which will attempt to recycle connections */ -@deprecated("Use BlazeClientBuilder", "0.19.0-M2") -object Http1Client { - - /** Construct a new PooledHttp1Client - * - * @param config blaze client configuration options - */ - private def resource[F[_]](config: BlazeClientConfig)(implicit - F: ConcurrentEffect[F]): Resource[F, Client[F]] = { - val http1: ConnectionBuilder[F, BlazeConnection[F]] = new Http1Support( - sslContextOption = config.sslContext.fold[SSLContextOption](SSLContextOption.NoSSL)( - SSLContextOption.Provided.apply), - bufferSize = config.bufferSize, - asynchronousChannelGroup = config.group, - executionContext = config.executionContext, - scheduler = bits.ClientTickWheel, - checkEndpointIdentification = config.checkEndpointIdentification, - maxResponseLineSize = config.maxResponseLineSize, - maxHeaderLength = config.maxHeaderLength, - maxChunkSize = config.maxChunkSize, - chunkBufferMaxSize = config.chunkBufferMaxSize, - parserMode = if (config.lenientParser) ParserMode.Lenient else ParserMode.Strict, - userAgent = config.userAgent, - channelOptions = ChannelOptions(Vector.empty), - connectTimeout = Duration.Inf, - idleTimeout = Duration.Inf, - getAddress = BlazeClientBuilder.getAddress(_) - ).makeClient - - Resource - .make( - ConnectionManager - .pool( - builder = http1, - maxTotal = config.maxTotalConnections, - maxWaitQueueLimit = config.maxWaitQueueLimit, - maxConnectionsPerRequestKey = config.maxConnectionsPerRequestKey, - responseHeaderTimeout = config.responseHeaderTimeout, - requestTimeout = config.requestTimeout, - executionContext = config.executionContext - ))(_.shutdown) - .map(pool => BlazeClient(pool, config, pool.shutdown, config.executionContext)) - } - - def stream[F[_]](config: BlazeClientConfig = BlazeClientConfig.defaultConfig)(implicit - F: ConcurrentEffect[F]): Stream[F, Client[F]] = - Stream.resource(resource(config)) -} diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Connection.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Connection.scala index cf98847951a..8c94679b100 100644 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Connection.scala +++ b/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Connection.scala @@ -18,7 +18,8 @@ package org.http4s package blaze package client -import cats.effect._ +import cats.effect.kernel.{Async, Outcome, Resource} +import cats.effect.std.Dispatcher import cats.effect.implicits._ import cats.syntax.all._ import fs2._ @@ -48,11 +49,13 @@ private final class Http1Connection[F[_]]( override val chunkBufferMaxSize: Int, parserMode: ParserMode, userAgent: Option[`User-Agent`], - idleTimeoutStage: Option[IdleTimeoutStage[ByteBuffer]] -)(implicit protected val F: ConcurrentEffect[F]) + idleTimeoutStage: Option[IdleTimeoutStage[ByteBuffer]], + override val dispatcher: Dispatcher[F] +)(implicit protected val F: Async[F]) extends Http1Stage[F] with BlazeConnection[F] { import Http1Connection._ + import Resource.ExitCase override def name: String = getClass.getName private val parser = @@ -205,12 +208,12 @@ private final class Http1Connection[F[_]]( } val idleTimeoutF = idleTimeoutStage match { - case Some(stage) => F.async[TimeoutException](stage.setTimeout) + case Some(stage) => F.async_[TimeoutException](stage.setTimeout) case None => F.never[TimeoutException] } idleTimeoutF.start.flatMap { timeoutFiber => - val idleTimeoutS = timeoutFiber.join.attempt.map { + val idleTimeoutS = timeoutFiber.joinWithNever.attempt.map { case Right(t) => Left(t): Either[Throwable, Unit] case Left(t) => Left(t): Either[Throwable, Unit] } @@ -235,11 +238,11 @@ private final class Http1Connection[F[_]]( // We need to wait for the write to complete so that by the time we attempt to recycle the connection it is fully idle. ).map(response => Resource.make(F.pure(writeFiber))(_.join.attempt.void).as(response))) { - case (_, ExitCase.Completed) => F.unit - case (writeFiber, ExitCase.Canceled | ExitCase.Error(_)) => writeFiber.cancel + case (_, Outcome.Succeeded(_)) => F.unit + case (writeFiber, Outcome.Canceled() | Outcome.Errored(_)) => writeFiber.cancel } - F.race(response, timeoutFiber.join) + F.race(response, timeoutFiber.joinWithNever) .flatMap { case Left(r) => F.pure(r) @@ -256,13 +259,23 @@ private final class Http1Connection[F[_]]( doesntHaveBody: Boolean, idleTimeoutS: F[Either[Throwable, Unit]], idleRead: Option[Future[ByteBuffer]]): F[Response[F]] = - F.async[Response[F]](cb => - idleRead match { - case Some(read) => - handleRead(read, cb, closeOnFinish, doesntHaveBody, "Initial Read", idleTimeoutS) - case None => - handleRead(channelRead(), cb, closeOnFinish, doesntHaveBody, "Initial Read", idleTimeoutS) - }) + F.async[Response[F]] { cb => + F.delay { + idleRead match { + case Some(read) => + handleRead(read, cb, closeOnFinish, doesntHaveBody, "Initial Read", idleTimeoutS) + case None => + handleRead( + channelRead(), + cb, + closeOnFinish, + doesntHaveBody, + "Initial Read", + idleTimeoutS) + } + None + } + } // this method will get some data, and try to continue parsing using the implicit ec private def readAndParsePrelude( @@ -370,12 +383,12 @@ private final class Http1Connection[F[_]]( attributes -> rawBody } else attributes -> rawBody.onFinalizeCaseWeak { - case ExitCase.Completed => - Async.shift(executionContext) *> F.delay { trailerCleanup(); cleanup(); } - case ExitCase.Error(_) | ExitCase.Canceled => - Async.shift(executionContext) *> F.delay { + case ExitCase.Succeeded => + F.delay { trailerCleanup(); cleanup(); }.evalOn(executionContext) + case ExitCase.Errored(_) | ExitCase.Canceled => + F.delay { trailerCleanup(); cleanup(); stageShutdown() - } + }.evalOn(executionContext) } } cb( diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Support.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Support.scala index 561f287829e..762310dd406 100644 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Support.scala +++ b/blaze-client/src/main/scala/org/http4s/blaze/client/Http1Support.scala @@ -18,8 +18,8 @@ package org.http4s package blaze package client -import cats.effect._ - +import cats.effect.kernel.Async +import cats.effect.std.Dispatcher import java.net.InetSocketAddress import java.nio.ByteBuffer import java.nio.channels.AsynchronousChannelGroup @@ -58,8 +58,9 @@ final private class Http1Support[F[_]]( channelOptions: ChannelOptions, connectTimeout: Duration, idleTimeout: Duration, + dispatcher: Dispatcher[F], getAddress: RequestKey => Either[Throwable, InetSocketAddress] -)(implicit F: ConcurrentEffect[F]) { +)(implicit F: Async[F]) { private val connectionManager = new ClientChannelFactory( bufferSize, asynchronousChannelGroup, @@ -109,7 +110,8 @@ final private class Http1Support[F[_]]( chunkBufferMaxSize = chunkBufferMaxSize, parserMode = parserMode, userAgent = userAgent, - idleTimeoutStage = idleTimeoutStage + idleTimeoutStage = idleTimeoutStage, + dispatcher = dispatcher ) ssl.map { sslStage => diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/PoolManager.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/PoolManager.scala index f5a80bd0804..0aeb3fee951 100644 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/PoolManager.scala +++ b/blaze-client/src/main/scala/org/http4s/blaze/client/PoolManager.scala @@ -18,9 +18,10 @@ package org.http4s package blaze package client -import cats.effect._ -import cats.effect.concurrent.Semaphore import cats.syntax.all._ +import cats.effect._ +import cats.effect.syntax.all._ +import cats.effect.std.Semaphore import java.time.Instant import org.http4s.client.{Connection, ConnectionBuilder, RequestKey} import org.http4s.internal.CollectionCompat @@ -31,9 +32,6 @@ import scala.concurrent.duration._ import scala.util.Random final case class WaitQueueFullFailure() extends RuntimeException { - @deprecated("Use `getMessage` instead", "0.20.0") - def message: String = getMessage - override def getMessage: String = "Wait queue is full" } @@ -45,7 +43,7 @@ private final class PoolManager[F[_], A <: Connection[F]]( responseHeaderTimeout: Duration, requestTimeout: Duration, semaphore: Semaphore[F], - implicit private val executionContext: ExecutionContext)(implicit F: Concurrent[F]) + implicit private val executionContext: ExecutionContext)(implicit F: Async[F]) extends ConnectionManager.Stateful[F, A] { self => private sealed case class Waiting( key: RequestKey, @@ -115,12 +113,14 @@ private final class PoolManager[F[_], A <: Connection[F]]( private def createConnection(key: RequestKey, callback: Callback[NextConnection]): F[Unit] = F.ifM(F.delay(numConnectionsCheckHolds(key)))( incrConnection(key) *> F.start { - Async.shift(executionContext) *> builder(key).attempt.flatMap { - case Right(conn) => - F.delay(callback(Right(NextConnection(conn, fresh = true)))) - case Left(error) => - disposeConnection(key, None) *> F.delay(callback(Left(error))) - } + builder(key).attempt + .flatMap { + case Right(conn) => + F.delay(callback(Right(NextConnection(conn, fresh = true)))) + case Left(error) => + disposeConnection(key, None) *> F.delay(callback(Left(error))) + } + .evalOn(executionContext) }.void, addToWaitQueue(key, callback) ) @@ -164,8 +164,8 @@ private final class PoolManager[F[_], A <: Connection[F]]( * @return An effect of NextConnection */ def borrow(key: RequestKey): F[NextConnection] = - F.asyncF { callback => - semaphore.withPermit { + F.async { callback => + semaphore.permit.use { _ => if (!isClosed) { def go(): F[Unit] = getConnectionFromQueue(key).flatMap { @@ -213,10 +213,9 @@ private final class PoolManager[F[_], A <: Connection[F]]( addToWaitQueue(key, callback) } - F.delay(logger.debug(s"Requesting connection for $key: $stats")) *> - go() + F.delay(logger.debug(s"Requesting connection for $key: $stats")).productR(go()).as(None) } else - F.delay(callback(Left(new IllegalStateException("Connection pool is closed")))) + F.delay(callback(Left(new IllegalStateException("Connection pool is closed")))).as(None) } } @@ -292,7 +291,7 @@ private final class PoolManager[F[_], A <: Connection[F]]( * @return An effect of Unit */ def release(connection: A): F[Unit] = - semaphore.withPermit { + semaphore.permit.use { _ => val key = connection.requestKey logger.debug(s"Recycling connection for $key: $stats") if (connection.isRecyclable) @@ -322,7 +321,7 @@ private final class PoolManager[F[_], A <: Connection[F]]( * @return An effect of Unit */ override def invalidate(connection: A): F[Unit] = - semaphore.withPermit { + semaphore.permit.use { _ => val key = connection.requestKey decrConnection(key) *> F.delay(if (!connection.isClosed) connection.shutdown()) *> @@ -347,7 +346,7 @@ private final class PoolManager[F[_], A <: Connection[F]]( * @param connection An Option of a Connection to Dispose Of. */ private def disposeConnection(key: RequestKey, connection: Option[A]): F[Unit] = - semaphore.withPermit { + semaphore.permit.use { _ => F.delay(logger.debug(s"Disposing of connection for $key: $stats")) *> decrConnection(key) *> F.delay { @@ -365,7 +364,7 @@ private final class PoolManager[F[_], A <: Connection[F]]( * @return An effect Of Unit */ def shutdown: F[Unit] = - semaphore.withPermit { + semaphore.permit.use { _ => F.delay { logger.info(s"Shutting down connection pool: $stats") if (!isClosed) { diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/bits.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/bits.scala index b0fa0175e90..650cd35ae7a 100644 --- a/blaze-client/src/main/scala/org/http4s/blaze/client/bits.scala +++ b/blaze-client/src/main/scala/org/http4s/blaze/client/bits.scala @@ -20,7 +20,6 @@ import java.security.SecureRandom import java.security.cert.X509Certificate import javax.net.ssl.{SSLContext, X509TrustManager} import org.http4s.{BuildInfo, ProductId} -import org.http4s.blaze.util.TickWheelExecutor import org.http4s.headers.`User-Agent` import scala.concurrent.duration._ @@ -33,9 +32,6 @@ private[http4s] object bits { val DefaultMaxTotalConnections = 10 val DefaultMaxWaitQueueLimit = 256 - @deprecated("Use org.http4s.blazecore.tickWheelResource", "0.19.1") - lazy val ClientTickWheel = new TickWheelExecutor() - /** Caution: trusts all certificates and disables endpoint identification */ lazy val TrustingSslContext: SSLContext = { val trustManager = new X509TrustManager { diff --git a/blaze-client/src/test/scala-2.13/org/http4s/client/blaze/BlazeClient213Suite.scala b/blaze-client/src/test/scala-2.13/org/http4s/client/blaze/BlazeClient213Suite.scala index 4e3964d7f2b..4d7807b7521 100644 --- a/blaze-client/src/test/scala-2.13/org/http4s/client/blaze/BlazeClient213Suite.scala +++ b/blaze-client/src/test/scala-2.13/org/http4s/client/blaze/BlazeClient213Suite.scala @@ -18,7 +18,6 @@ package org.http4s.client package blaze import cats.effect._ -import cats.effect.concurrent.Ref import cats.syntax.all._ import fs2.Stream import org.http4s._ @@ -32,7 +31,7 @@ class BlazeClient213Suite extends BlazeClientBase { override def munitTimeout: Duration = new FiniteDuration(50, TimeUnit.SECONDS) test("reset request timeout".flaky) { - val addresses = jettyServer().addresses + val addresses = server().addresses val address = addresses.head val name = address.getHostName val port = address.getPort @@ -43,104 +42,110 @@ class BlazeClient213Suite extends BlazeClientBase { builder(1, requestTimeout = 2.second).resource.use { client => val submit = client.status(Request[IO](uri = Uri.fromString(s"http://$name:$port/simple").yolo)) - submit *> munitTimer.sleep(3.seconds) *> submit + submit *> IO.sleep(3.seconds) *> submit } } .assertEquals(Status.Ok) } test("Blaze Http1Client should behave and not deadlock") { - val addresses = jettyServer().addresses + val addresses = server().addresses val hosts = addresses.map { address => val name = address.getHostName val port = address.getPort Uri.fromString(s"http://$name:$port/simple").yolo } - builder(3).resource.use { client => - (1 to Runtime.getRuntime.availableProcessors * 5).toList - .parTraverse { _ => - val h = hosts(Random.nextInt(hosts.length)) - client.expect[String](h).map(_.nonEmpty) - } - .map(_.forall(identity)) - }.assert + builder(3).resource + .use { client => + (1 to Runtime.getRuntime.availableProcessors * 5).toList + .parTraverse { _ => + val h = hosts(Random.nextInt(hosts.length)) + client.expect[String](h).map(_.nonEmpty) + } + .map(_.forall(identity)) + } + .assertEquals(true) } test("behave and not deadlock on failures with parTraverse") { - val addresses = jettyServer().addresses - builder(3).resource.use { client => - val failedHosts = addresses.map { address => - val name = address.getHostName - val port = address.getPort - Uri.fromString(s"http://$name:$port/internal-server-error").yolo - } - - val successHosts = addresses.map { address => - val name = address.getHostName - val port = address.getPort - Uri.fromString(s"http://$name:$port/simple").yolo - } - - val failedRequests = - (1 to Runtime.getRuntime.availableProcessors * 5).toList.parTraverse { _ => - val h = failedHosts(Random.nextInt(failedHosts.length)) - client.expect[String](h) + val addresses = server().addresses + builder(3).resource + .use { client => + val failedHosts = addresses.map { address => + val name = address.getHostName + val port = address.getPort + Uri.fromString(s"http://$name:$port/internal-server-error").yolo } - val sucessRequests = - (1 to Runtime.getRuntime.availableProcessors * 5).toList.parTraverse { _ => - val h = successHosts(Random.nextInt(successHosts.length)) - client.expect[String](h).map(_.nonEmpty) + val successHosts = addresses.map { address => + val name = address.getHostName + val port = address.getPort + Uri.fromString(s"http://$name:$port/simple").yolo } - val allRequests = for { - _ <- failedRequests.handleErrorWith(_ => IO.unit).replicateA(5) - r <- sucessRequests - } yield r - - allRequests - .map(_.forall(identity)) - }.assert + val failedRequests = + (1 to Runtime.getRuntime.availableProcessors * 5).toList.parTraverse { _ => + val h = failedHosts(Random.nextInt(failedHosts.length)) + client.expect[String](h) + } + + val sucessRequests = + (1 to Runtime.getRuntime.availableProcessors * 5).toList.parTraverse { _ => + val h = successHosts(Random.nextInt(successHosts.length)) + client.expect[String](h).map(_.nonEmpty) + } + + val allRequests = for { + _ <- failedRequests.handleErrorWith(_ => IO.unit).replicateA(5) + r <- sucessRequests + } yield r + + allRequests + .map(_.forall(identity)) + } + .assertEquals(true) } test("Blaze Http1Client should behave and not deadlock on failures with parSequence".flaky) { - val addresses = jettyServer().addresses - builder(3).resource.use { client => - val failedHosts = addresses.map { address => - val name = address.getHostName - val port = address.getPort - Uri.fromString(s"http://$name:$port/internal-server-error").yolo - } + val addresses = server().addresses + builder(3).resource + .use { client => + val failedHosts = addresses.map { address => + val name = address.getHostName + val port = address.getPort + Uri.fromString(s"http://$name:$port/internal-server-error").yolo + } - val successHosts = addresses.map { address => - val name = address.getHostName - val port = address.getPort - Uri.fromString(s"http://$name:$port/simple").yolo - } + val successHosts = addresses.map { address => + val name = address.getHostName + val port = address.getPort + Uri.fromString(s"http://$name:$port/simple").yolo + } - val failedRequests = (1 to Runtime.getRuntime.availableProcessors * 5).toList.map { _ => - val h = failedHosts(Random.nextInt(failedHosts.length)) - client.expect[String](h) - }.parSequence + val failedRequests = (1 to Runtime.getRuntime.availableProcessors * 5).toList.map { _ => + val h = failedHosts(Random.nextInt(failedHosts.length)) + client.expect[String](h) + }.parSequence - val sucessRequests = (1 to Runtime.getRuntime.availableProcessors * 5).toList.map { _ => - val h = successHosts(Random.nextInt(successHosts.length)) - client.expect[String](h).map(_.nonEmpty) - }.parSequence + val sucessRequests = (1 to Runtime.getRuntime.availableProcessors * 5).toList.map { _ => + val h = successHosts(Random.nextInt(successHosts.length)) + client.expect[String](h).map(_.nonEmpty) + }.parSequence - val allRequests = for { - _ <- failedRequests.handleErrorWith(_ => IO.unit).replicateA(5) - r <- sucessRequests - } yield r + val allRequests = for { + _ <- failedRequests.handleErrorWith(_ => IO.unit).replicateA(5) + r <- sucessRequests + } yield r - allRequests - .map(_.forall(identity)) - }.assert + allRequests + .map(_.forall(identity)) + } + .assertEquals(true) } test("call a second host after reusing connections on a first") { - val addresses = jettyServer().addresses + val addresses = server().addresses // https://github.com/http4s/http4s/pull/2546 builder(maxConnectionsPerRequestKey = Int.MaxValue, maxTotalConnections = 5).resource .use { client => diff --git a/blaze-client/src/test/scala/org/http4s/blaze/client/BlazeClientBase.scala b/blaze-client/src/test/scala/org/http4s/blaze/client/BlazeClientBase.scala index e7725933883..8014a50f44c 100644 --- a/blaze-client/src/test/scala/org/http4s/blaze/client/BlazeClientBase.scala +++ b/blaze-client/src/test/scala/org/http4s/blaze/client/BlazeClientBase.scala @@ -18,12 +18,12 @@ package org.http4s.blaze package client import cats.effect._ +import cats.syntax.all._ +import com.sun.net.httpserver.HttpHandler import javax.net.ssl.SSLContext -import javax.servlet.ServletOutputStream -import javax.servlet.http.{HttpServlet, HttpServletRequest, HttpServletResponse} import org.http4s._ import org.http4s.blaze.util.TickWheelExecutor -import org.http4s.client.JettyScaffold +import org.http4s.client.ServerScaffold import org.http4s.client.testroutes.GetRoutes import scala.concurrent.duration._ @@ -51,55 +51,69 @@ trait BlazeClientBase extends Http4sSuite { sslContextOption.fold[BlazeClientBuilder[IO]](builder.withoutSslContext)(builder.withSslContext) } - private def testServlet = - new HttpServlet { - override def doGet(req: HttpServletRequest, srv: HttpServletResponse): Unit = - GetRoutes.getPaths.get(req.getRequestURI) match { - case Some(response) => - val resp = response.unsafeRunSync() - srv.setStatus(resp.status.code) - resp.headers.foreach { h => - srv.addHeader(h.name.toString, h.value) - } - - val os: ServletOutputStream = srv.getOutputStream - - val writeBody: IO[Unit] = resp.body - .evalMap { byte => - IO(os.write(Array(byte))) + private def testHandler: HttpHandler = exchange => { + val io = exchange.getRequestMethod match { + case "GET" => + val path = exchange.getRequestURI.getPath + GetRoutes.getPaths.get(path) match { + case Some(responseIO) => + responseIO.flatMap { resp => + val prelude = IO.blocking { + resp.headers.foreach { h => + if (h.name =!= headers.`Content-Length`.name) + exchange.getResponseHeaders.add(h.name.toString, h.value) + } + exchange.sendResponseHeaders(resp.status.code, resp.contentLength.getOrElse(0L)) } - .compile - .drain - val flushOutputStream: IO[Unit] = IO(os.flush()) - (writeBody *> flushOutputStream).unsafeRunSync() - - case None => srv.sendError(404) + val body = + resp.body + .evalMap { byte => + IO.blocking(exchange.getResponseBody.write(Array(byte))) + } + .compile + .drain + val flush = IO.blocking(exchange.getResponseBody.flush()) + val close = IO.blocking(exchange.close()) + (prelude *> body *> flush).guarantee(close) + } + case None => + IO.blocking { + exchange.sendResponseHeaders(404, -1) + exchange.close() + } } - - override def doPost(req: HttpServletRequest, resp: HttpServletResponse): Unit = - req.getRequestURI match { + case "POST" => + exchange.getRequestURI.getPath match { case "/respond-and-close-immediately" => // We don't consume the req.getInputStream (the request entity). That means that: // - The client may receive the response before sending the whole request // - Jetty will send a "Connection: close" header and a TCP FIN+ACK along with the response, closing the connection. - resp.getOutputStream.print("a") - resp.setStatus(Status.Ok.code) - + exchange.sendResponseHeaders(200, 1L) + exchange.getResponseBody.write(Array("a".toByte)) + exchange.getResponseBody.flush() + exchange.close() case "/respond-and-close-immediately-no-body" => // We don't consume the req.getInputStream (the request entity). That means that: // - The client may receive the response before sending the whole request // - Jetty will send a "Connection: close" header and a TCP FIN+ACK along with the response, closing the connection. - resp.setStatus(Status.Ok.code) + exchange.sendResponseHeaders(204, 0L) + exchange.close() case "/process-request-entity" => // We wait for the entire request to arrive before sending a response. That's how servers normally behave. var result: Int = 0 while (result != -1) - result = req.getInputStream.read() - resp.setStatus(Status.Ok.code) + result = exchange.getRequestBody.read() + exchange.sendResponseHeaders(204, 0L) + exchange.close() + } + IO.blocking { + exchange.sendResponseHeaders(204, -1) + exchange.close() } - } + io.start.unsafeRunAndForget() + } - val jettyServer = resourceSuiteFixture("http", JettyScaffold[IO](2, false, testServlet)) - val jettySslServer = resourceSuiteFixture("https", JettyScaffold[IO](1, true, testServlet)) + val server = resourceSuiteFixture("http", ServerScaffold[IO](2, false, testHandler)) + val secureServer = resourceSuiteFixture("https", ServerScaffold[IO](1, true, testHandler)) } diff --git a/blaze-client/src/test/scala/org/http4s/blaze/client/BlazeClientSuite.scala b/blaze-client/src/test/scala/org/http4s/blaze/client/BlazeClientSuite.scala index 702c62823fe..6b79970c7c6 100644 --- a/blaze-client/src/test/scala/org/http4s/blaze/client/BlazeClientSuite.scala +++ b/blaze-client/src/test/scala/org/http4s/blaze/client/BlazeClientSuite.scala @@ -18,7 +18,6 @@ package org.http4s.blaze package client import cats.effect._ -import cats.effect.concurrent.Deferred import cats.syntax.all._ import fs2.Stream import java.util.concurrent.TimeoutException @@ -31,7 +30,7 @@ class BlazeClientSuite extends BlazeClientBase { test( "Blaze Http1Client should raise error NoConnectionAllowedException if no connections are permitted for key") { - val sslAddress = jettySslServer().addresses.head + val sslAddress = secureServer().addresses.head val name = sslAddress.getHostName val port = sslAddress.getPort val u = Uri.fromString(s"https://$name:$port/simple").yolo @@ -40,32 +39,33 @@ class BlazeClientSuite extends BlazeClientBase { } test("Blaze Http1Client should make simple https requests") { - val sslAddress = jettySslServer().addresses.head + val sslAddress = secureServer().addresses.head val name = sslAddress.getHostName val port = sslAddress.getPort val u = Uri.fromString(s"https://$name:$port/simple").yolo val resp = builder(1).resource.use(_.expect[String](u)) - resp.map(_.length > 0).assert + resp.map(_.length > 0).assertEquals(true) } test("Blaze Http1Client should reject https requests when no SSLContext is configured") { - val sslAddress = jettySslServer().addresses.head + val sslAddress = secureServer().addresses.head val name = sslAddress.getHostName val port = sslAddress.getPort val u = Uri.fromString(s"https://$name:$port/simple").yolo val resp = builder(1, sslContextOption = None).resource .use(_.expect[String](u)) .attempt - resp.map { - case Left(_: ConnectionFailure) => true - case _ => false - }.assert + resp + .map { + case Left(_: ConnectionFailure) => true + case _ => false + } + .assertEquals(true) } test("Blaze Http1Client should obey response header timeout") { - - val addresses = jettyServer().addresses - val address = addresses.head + val addresses = server().addresses + val address = addresses(0) val name = address.getHostName val port = address.getPort builder(1, responseHeaderTimeout = 100.millis).resource @@ -77,8 +77,8 @@ class BlazeClientSuite extends BlazeClientBase { } test("Blaze Http1Client should unblock waiting connections") { - val addresses = jettyServer().addresses - val address = addresses.head + val addresses = server().addresses + val address = addresses(0) val name = address.getHostName val port = address.getPort builder(1, responseHeaderTimeout = 20.seconds).resource @@ -90,12 +90,12 @@ class BlazeClientSuite extends BlazeClientBase { } yield r } .map(_.isRight) - .assert + .assertEquals(true) } test("Blaze Http1Client should drain waiting connections after shutdown") { - val addresses = jettyServer().addresses - val address = addresses.head + val addresses = server().addresses + val address = addresses(0) val name = address.getHostName val port = address.getPort @@ -113,23 +113,23 @@ class BlazeClientSuite extends BlazeClientBase { .start // Wait 100 millis to shut down - IO.sleep(100.millis) *> resp.flatMap(_.join) + IO.sleep(100.millis) *> resp.flatMap(_.joinWithNever) } - resp.assert + resp.assertEquals(true) } test( "Blaze Http1Client should stop sending data when the server sends response and closes connection") { // https://datatracker.ietf.org/doc/html/rfc2616#section-8.2.2 - val addresses = jettyServer().addresses + val addresses = server().addresses val address = addresses.head val name = address.getHostName val port = address.getPort Deferred[IO, Unit] .flatMap { reqClosed => builder(1, requestTimeout = 2.seconds).resource.use { client => - val body = Stream(0.toByte).repeat.onFinalizeWeak(reqClosed.complete(())) + val body = Stream(0.toByte).repeat.onFinalizeWeak(reqClosed.complete(()).void) val req = Request[IO]( method = Method.POST, uri = Uri.fromString(s"http://$name:$port/respond-and-close-immediately").yolo @@ -145,14 +145,14 @@ class BlazeClientSuite extends BlazeClientBase { // https://datatracker.ietf.org/doc/html/rfc2616#section-8.2.2 // Receiving a response with and without body exercises different execution path in blaze client. - val addresses = jettyServer().addresses + val addresses = server().addresses val address = addresses.head val name = address.getHostName val port = address.getPort Deferred[IO, Unit] .flatMap { reqClosed => builder(1, requestTimeout = 2.seconds).resource.use { client => - val body = Stream(0.toByte).repeat.onFinalizeWeak(reqClosed.complete(())) + val body = Stream(0.toByte).repeat.onFinalizeWeak(reqClosed.complete(()).void) val req = Request[IO]( method = Method.POST, uri = Uri.fromString(s"http://$name:$port/respond-and-close-immediately-no-body").yolo @@ -165,7 +165,7 @@ class BlazeClientSuite extends BlazeClientBase { test( "Blaze Http1Client should fail with request timeout if the request body takes too long to send") { - val addresses = jettyServer().addresses + val addresses = server().addresses val address = addresses.head val name = address.getHostName val port = address.getPort @@ -188,7 +188,7 @@ class BlazeClientSuite extends BlazeClientBase { test( "Blaze Http1Client should fail with response header timeout if the request body takes too long to send") { - val addresses = jettyServer().addresses + val addresses = server().addresses val address = addresses.head val name = address.getHostName val port = address.getPort @@ -210,7 +210,7 @@ class BlazeClientSuite extends BlazeClientBase { } test("Blaze Http1Client should doesn't leak connection on timeout") { - val addresses = jettyServer().addresses + val addresses = server().addresses val address = addresses.head val name = address.getHostName val port = address.getPort @@ -236,17 +236,12 @@ class BlazeClientSuite extends BlazeClientBase { .use { client => client.status(Request[IO](uri = uri"http://example.invalid/")) } - .attempt - .map { - case Left(e: ConnectionFailure) => - e.getMessage === "Error connecting to http://example.invalid using address example.invalid:80 (unresolved: true)" - case _ => false - } - .assert + .interceptMessage[ConnectionFailure]( + "Error connecting to http://example.invalid using address example.invalid:80 (unresolved: true)") } - test("Keeps stats") { - val addresses = jettyServer().addresses + test("Keeps stats".flaky) { + val addresses = server().addresses val address = addresses.head val name = address.getHostName val port = address.getPort diff --git a/blaze-client/src/test/scala/org/http4s/blaze/client/ClientTimeoutSuite.scala b/blaze-client/src/test/scala/org/http4s/blaze/client/ClientTimeoutSuite.scala index 4ed21e69685..2f29f40d208 100644 --- a/blaze-client/src/test/scala/org/http4s/blaze/client/ClientTimeoutSuite.scala +++ b/blaze-client/src/test/scala/org/http4s/blaze/client/ClientTimeoutSuite.scala @@ -19,10 +19,10 @@ package blaze package client import cats.effect._ -import cats.effect.concurrent.Deferred +import cats.effect.kernel.Deferred +import cats.effect.std.{Dispatcher, Queue} import cats.syntax.all._ import fs2.Stream -import fs2.concurrent.Queue import java.io.IOException import java.nio.ByteBuffer @@ -32,16 +32,19 @@ import org.http4s.blaze.util.TickWheelExecutor import org.http4s.blazecore.{IdleTimeoutStage, QueueTestHead, SeqTestHead, SlowTestHead} import org.http4s.client.{Client, RequestKey} import org.http4s.syntax.all._ +import org.http4s.testing.DispatcherIOFixture import scala.concurrent.TimeoutException import scala.concurrent.duration._ -class ClientTimeoutSuite extends Http4sSuite { +class ClientTimeoutSuite extends Http4sSuite with DispatcherIOFixture { def tickWheelFixture = ResourceFixture( Resource.make(IO(new TickWheelExecutor(tick = 50.millis)))(tickWheel => IO(tickWheel.shutdown()))) + def fixture = (tickWheelFixture, dispatcher).mapN(FunFixture.map2(_, _)) + val www_foo_com = uri"http://www.foo.com" val FooRequest = Request[IO](uri = www_foo_com) val FooRequestKey = RequestKey.fromRequest(FooRequest) @@ -50,6 +53,7 @@ class ClientTimeoutSuite extends Http4sSuite { private def mkConnection( requestKey: RequestKey, tickWheel: TickWheelExecutor, + dispatcher: Dispatcher[IO], idleTimeout: Duration = Duration.Inf): Http1Connection[IO] = { val idleTimeoutStage = makeIdleTimeoutStage(idleTimeout, tickWheel) @@ -62,7 +66,8 @@ class ClientTimeoutSuite extends Http4sSuite { chunkBufferMaxSize = 1024 * 1024, parserMode = ParserMode.Strict, userAgent = None, - idleTimeoutStage = idleTimeoutStage + idleTimeoutStage = idleTimeoutStage, + dispatcher = dispatcher ) val builder = LeafBuilder(connection) @@ -98,23 +103,23 @@ class ClientTimeoutSuite extends Http4sSuite { ) } - tickWheelFixture.test("Idle timeout on slow response") { tickWheel => - val tail = mkConnection(FooRequestKey, tickWheel, idleTimeout = 1.second) + fixture.test("Idle timeout on slow response") { case (tickWheel, dispatcher) => + val tail = mkConnection(FooRequestKey, tickWheel, dispatcher, idleTimeout = 1.second) val h = new SlowTestHead(List(mkBuffer(resp)), 10.seconds, tickWheel) val c = mkClient(h, tail, tickWheel)() c.fetchAs[String](FooRequest).intercept[TimeoutException] } - tickWheelFixture.test("Request timeout on slow response") { tickWheel => - val tail = mkConnection(FooRequestKey, tickWheel) + fixture.test("Request timeout on slow response") { case (tickWheel, dispatcher) => + val tail = mkConnection(FooRequestKey, tickWheel, dispatcher) val h = new SlowTestHead(List(mkBuffer(resp)), 10.seconds, tickWheel) val c = mkClient(h, tail, tickWheel)(requestTimeout = 1.second) c.fetchAs[String](FooRequest).intercept[TimeoutException] } - tickWheelFixture.test("Idle timeout on slow POST body") { tickWheel => + fixture.test("Idle timeout on slow POST body") { case (tickWheel, dispatcher) => (for { d <- Deferred[IO, Unit] body = @@ -124,17 +129,21 @@ class ClientTimeoutSuite extends Http4sSuite { .take(4) .onFinalizeWeak[IO](d.complete(()).void) req = Request(method = Method.POST, uri = www_foo_com, body = body) - tail = mkConnection(RequestKey.fromRequest(req), tickWheel, idleTimeout = 1.second) + tail = mkConnection( + RequestKey.fromRequest(req), + tickWheel, + dispatcher, + idleTimeout = 1.second) q <- Queue.unbounded[IO, Option[ByteBuffer]] h = new QueueTestHead(q) (f, b) = resp.splitAt(resp.length - 1) - _ <- (q.enqueue1(Some(mkBuffer(f))) >> d.get >> q.enqueue1(Some(mkBuffer(b)))).start + _ <- (q.offer(Some(mkBuffer(f))) >> d.get >> q.offer(Some(mkBuffer(b)))).start c = mkClient(h, tail, tickWheel)() s <- c.fetchAs[String](req) } yield s).intercept[TimeoutException] } - tickWheelFixture.test("Not timeout on only marginally slow POST body") { tickWheel => + fixture.test("Not timeout on only marginally slow POST body") { case (tickWheel, dispatcher) => def dataStream(n: Int): EntityBody[IO] = { val interval = 100.millis Stream @@ -145,7 +154,8 @@ class ClientTimeoutSuite extends Http4sSuite { val req = Request[IO](method = Method.POST, uri = www_foo_com, body = dataStream(4)) - val tail = mkConnection(RequestKey.fromRequest(req), tickWheel, idleTimeout = 10.second) + val tail = + mkConnection(RequestKey.fromRequest(req), tickWheel, dispatcher, idleTimeout = 10.seconds) val (f, b) = resp.splitAt(resp.length - 1) val h = new SeqTestHead(Seq(f, b).map(mkBuffer)) val c = mkClient(h, tail, tickWheel)(requestTimeout = 30.seconds) @@ -153,8 +163,8 @@ class ClientTimeoutSuite extends Http4sSuite { c.fetchAs[String](req).assertEquals("done") } - tickWheelFixture.test("Request timeout on slow response body") { tickWheel => - val tail = mkConnection(FooRequestKey, tickWheel, idleTimeout = 10.second) + fixture.test("Request timeout on slow response body") { case (tickWheel, dispatcher) => + val tail = mkConnection(FooRequestKey, tickWheel, dispatcher, idleTimeout = 10.seconds) val (f, b) = resp.splitAt(resp.length - 1) val h = new SlowTestHead(Seq(f, b).map(mkBuffer), 1500.millis, tickWheel) val c = mkClient(h, tail, tickWheel)(requestTimeout = 1.second) @@ -162,32 +172,32 @@ class ClientTimeoutSuite extends Http4sSuite { c.fetchAs[String](FooRequest).intercept[TimeoutException] } - tickWheelFixture.test("Idle timeout on slow response body") { tickWheel => - val tail = mkConnection(FooRequestKey, tickWheel, idleTimeout = 500.millis) + fixture.test("Idle timeout on slow response body") { case (tickWheel, dispatcher) => + val tail = mkConnection(FooRequestKey, tickWheel, dispatcher, idleTimeout = 500.millis) val (f, b) = resp.splitAt(resp.length - 1) (for { q <- Queue.unbounded[IO, Option[ByteBuffer]] - _ <- q.enqueue1(Some(mkBuffer(f))) - _ <- (IO.sleep(1500.millis) >> q.enqueue1(Some(mkBuffer(b)))).start + _ <- q.offer(Some(mkBuffer(f))) + _ <- (IO.sleep(1500.millis) >> q.offer(Some(mkBuffer(b)))).start h = new QueueTestHead(q) c = mkClient(h, tail, tickWheel)() s <- c.fetchAs[String](FooRequest) } yield s).intercept[TimeoutException] } - tickWheelFixture.test("Response head timeout on slow header") { tickWheel => - val tail = mkConnection(FooRequestKey, tickWheel) + fixture.test("Response head timeout on slow header") { case (tickWheel, dispatcher) => + val tail = mkConnection(FooRequestKey, tickWheel, dispatcher) (for { q <- Queue.unbounded[IO, Option[ByteBuffer]] - _ <- (IO.sleep(10.seconds) >> q.enqueue1(Some(mkBuffer(resp)))).start + _ <- (IO.sleep(10.seconds) >> q.offer(Some(mkBuffer(resp)))).start h = new QueueTestHead(q) c = mkClient(h, tail, tickWheel)(responseHeaderTimeout = 500.millis) s <- c.fetchAs[String](FooRequest) } yield s).intercept[TimeoutException] } - tickWheelFixture.test("No Response head timeout on fast header") { tickWheel => - val tail = mkConnection(FooRequestKey, tickWheel) + fixture.test("No Response head timeout on fast header") { case (tickWheel, dispatcher) => + val tail = mkConnection(FooRequestKey, tickWheel, dispatcher) val (f, b) = resp.splitAt(resp.indexOf("\r\n\r\n" + 4)) val h = new SlowTestHead(Seq(f, b).map(mkBuffer), 125.millis, tickWheel) // header is split into two chunks, we wait for 10x diff --git a/blaze-client/src/test/scala/org/http4s/blaze/client/Http1ClientStageSuite.scala b/blaze-client/src/test/scala/org/http4s/blaze/client/Http1ClientStageSuite.scala index 1d6600a9531..28da25613fa 100644 --- a/blaze-client/src/test/scala/org/http4s/blaze/client/Http1ClientStageSuite.scala +++ b/blaze-client/src/test/scala/org/http4s/blaze/client/Http1ClientStageSuite.scala @@ -19,24 +19,26 @@ package blaze package client import cats.effect._ -import cats.effect.concurrent.Deferred +import cats.effect.kernel.Deferred +import cats.effect.std.{Dispatcher, Queue} import cats.syntax.all._ import fs2.Stream -import fs2.concurrent.Queue +import org.http4s.blaze.pipeline.Command.EOF import java.nio.ByteBuffer import java.nio.charset.StandardCharsets -import org.http4s.BuildInfo import org.http4s.blaze.client.bits.DefaultUserAgent -import org.http4s.blaze.pipeline.Command.EOF import org.http4s.blaze.pipeline.LeafBuilder import org.http4s.blazecore.{QueueTestHead, SeqTestHead, TestHead} +import org.http4s.BuildInfo import org.http4s.client.RequestKey import org.http4s.headers.`User-Agent` import org.http4s.syntax.all._ +import org.http4s.testing.DispatcherIOFixture import scala.concurrent.Future import scala.concurrent.duration._ -class Http1ClientStageSuite extends Http4sSuite { +class Http1ClientStageSuite extends Http4sSuite with DispatcherIOFixture { + val trampoline = org.http4s.blaze.util.Execution.trampoline val www_foo_test = uri"http://www.foo.test" @@ -50,15 +52,21 @@ class Http1ClientStageSuite extends Http4sSuite { private val fooConnection = ResourceFixture[Http1Connection[IO]] { - Resource[IO, Http1Connection[IO]] { - IO { - val connection = mkConnection(FooRequestKey) - (connection, IO.delay(connection.shutdown())) + for { + dispatcher <- Dispatcher[IO] + connection <- Resource[IO, Http1Connection[IO]] { + IO { + val connection = mkConnection(FooRequestKey, dispatcher) + (connection, IO.delay(connection.shutdown())) + } } - } + } yield connection } - private def mkConnection(key: RequestKey, userAgent: Option[`User-Agent`] = None) = + private def mkConnection( + key: RequestKey, + dispatcher: Dispatcher[IO], + userAgent: Option[`User-Agent`] = None) = new Http1Connection[IO]( key, executionContext = trampoline, @@ -68,15 +76,19 @@ class Http1ClientStageSuite extends Http4sSuite { chunkBufferMaxSize = 1024, parserMode = ParserMode.Strict, userAgent = userAgent, - idleTimeoutStage = None + idleTimeoutStage = None, + dispatcher = dispatcher ) private def mkBuffer(s: String): ByteBuffer = ByteBuffer.wrap(s.getBytes(StandardCharsets.ISO_8859_1)) - private def bracketResponse[T](req: Request[IO], resp: String): Resource[IO, Response[IO]] = { + private def bracketResponse[T]( + req: Request[IO], + resp: String, + dispatcher: Dispatcher[IO]): Resource[IO, Response[IO]] = { val stageResource = Resource(IO { - val stage = mkConnection(FooRequestKey) + val stage = mkConnection(FooRequestKey, dispatcher) val h = new SeqTestHead(resp.toSeq.map { chr => val b = ByteBuffer.allocate(1) b.put(chr.toByte).flip() @@ -109,10 +121,10 @@ class Http1ClientStageSuite extends Http4sSuite { b } .noneTerminate - .through(q.enqueue) + .evalMap(q.offer) .compile .drain).start - req0 = req.withBodyStream(req.body.onFinalizeWeak(d.complete(()))) + req0 = req.withBodyStream(req.body.onFinalizeWeak(d.complete(()).void)) response <- stage.runRequest(req0) result <- response.use(_.as[String]) _ <- IO(h.stageShutdown()) @@ -124,29 +136,30 @@ class Http1ClientStageSuite extends Http4sSuite { private def getSubmission( req: Request[IO], resp: String, + dispatcher: Dispatcher[IO], userAgent: Option[`User-Agent`] = None): IO[(String, String)] = { val key = RequestKey.fromRequest(req) - val tail = mkConnection(key, userAgent) + val tail = mkConnection(key, dispatcher, userAgent) getSubmission(req, resp, tail) } - test("Run a basic request".flaky) { - getSubmission(FooRequest, resp).map { case (request, response) => + dispatcher.test("Run a basic request".flaky) { dispatcher => + getSubmission(FooRequest, resp, dispatcher).map { case (request, response) => val statusLine = request.split("\r\n").apply(0) - assert(statusLine == "GET / HTTP/1.1") - assert(response == "done") + assertEquals(statusLine, "GET / HTTP/1.1") + assertEquals(response, "done") } } - test("Submit a request line with a query".flaky) { + dispatcher.test("Submit a request line with a query".flaky) { dispatcher => val uri = "/huh?foo=bar" val Right(parsed) = Uri.fromString("http://www.foo.test" + uri) val req = Request[IO](uri = parsed) - getSubmission(req, resp).map { case (request, response) => + getSubmission(req, resp, dispatcher).map { case (request, response) => val statusLine = request.split("\r\n").apply(0) - assert(statusLine == "GET " + uri + " HTTP/1.1") - assert(response == "done") + assertEquals(statusLine, "GET " + uri + " HTTP/1.1") + assertEquals(response, "done") } } @@ -174,39 +187,39 @@ class Http1ClientStageSuite extends Http4sSuite { .intercept[InvalidBodyException] } - test("Interpret a lack of length with a EOF as a valid message") { + dispatcher.test("Interpret a lack of length with a EOF as a valid message") { dispatcher => val resp = "HTTP/1.1 200 OK\r\n\r\ndone" - getSubmission(FooRequest, resp).map(_._2).assertEquals("done") + getSubmission(FooRequest, resp, dispatcher).map(_._2).assertEquals("done") } - test("Utilize a provided Host header".flaky) { + dispatcher.test("Utilize a provided Host header".flaky) { dispatcher => val resp = "HTTP/1.1 200 OK\r\n\r\ndone" val req = FooRequest.withHeaders(headers.Host("bar.test")) - getSubmission(req, resp).map { case (request, response) => + getSubmission(req, resp, dispatcher).map { case (request, response) => val requestLines = request.split("\r\n").toList assert(requestLines.contains("Host: bar.test")) assertEquals(response, "done") } } - test("Insert a User-Agent header") { + dispatcher.test("Insert a User-Agent header") { dispatcher => val resp = "HTTP/1.1 200 OK\r\n\r\ndone" - getSubmission(FooRequest, resp, DefaultUserAgent).map { case (request, response) => + getSubmission(FooRequest, resp, dispatcher, DefaultUserAgent).map { case (request, response) => val requestLines = request.split("\r\n").toList assert(requestLines.contains(s"User-Agent: http4s-blaze/${BuildInfo.version}")) assertEquals(response, "done") } } - test("Use User-Agent header provided in Request".flaky) { + dispatcher.test("Use User-Agent header provided in Request".flaky) { dispatcher => val resp = "HTTP/1.1 200 OK\r\n\r\ndone" val req = FooRequest.withHeaders(`User-Agent`(ProductId("myagent"))) - getSubmission(req, resp).map { case (request, response) => + getSubmission(req, resp, dispatcher).map { case (request, response) => val requestLines = request.split("\r\n").toList assert(requestLines.contains("User-Agent: myagent")) assertEquals(response, "done") @@ -224,25 +237,25 @@ class Http1ClientStageSuite extends Http4sSuite { } // TODO fs2 port - Currently is elevating the http version to 1.1 causing this test to fail - test("Allow an HTTP/1.0 request without a Host header".ignore) { + dispatcher.test("Allow an HTTP/1.0 request without a Host header".ignore) { dispatcher => val resp = "HTTP/1.0 200 OK\r\n\r\ndone" val req = Request[IO](uri = www_foo_test, httpVersion = HttpVersion.`HTTP/1.0`) - getSubmission(req, resp).map { case (request, response) => + getSubmission(req, resp, dispatcher).map { case (request, response) => assert(!request.contains("Host:")) assertEquals(response, "done") } } - test("Support flushing the prelude") { + dispatcher.test("Support flushing the prelude") { dispatcher => val req = Request[IO](uri = www_foo_test, httpVersion = HttpVersion.`HTTP/1.0`) /* * We flush the prelude first to test connection liveness in pooled * scenarios before we consume the body. Make sure we can handle * it. Ensure that we still get a well-formed response. */ - getSubmission(req, resp).map(_._2).assertEquals("done") + getSubmission(req, resp, dispatcher).map(_._2).assertEquals("done") } fooConnection.test("Not expect body if request was a HEAD request") { tail => @@ -272,8 +285,8 @@ class Http1ClientStageSuite extends Http4sSuite { val req = Request[IO](uri = www_foo_test, httpVersion = HttpVersion.`HTTP/1.1`) - test("Support trailer headers") { - val hs: IO[Headers] = bracketResponse(req, resp).use { (response: Response[IO]) => + dispatcher.test("Support trailer headers") { dispatcher => + val hs: IO[Headers] = bracketResponse(req, resp, dispatcher).use { (response: Response[IO]) => for { _ <- response.as[String] hs <- response.trailerHeaders @@ -283,8 +296,8 @@ class Http1ClientStageSuite extends Http4sSuite { hs.map(_.headers.mkString).assertEquals("Foo: Bar") } - test("Fail to get trailers before they are complete") { - val hs: IO[Headers] = bracketResponse(req, resp).use { (response: Response[IO]) => + dispatcher.test("Fail to get trailers before they are complete") { dispatcher => + val hs: IO[Headers] = bracketResponse(req, resp, dispatcher).use { (response: Response[IO]) => for { hs <- response.trailerHeaders } yield hs diff --git a/blaze-client/src/test/scala/org/http4s/blaze/client/PoolManagerSuite.scala b/blaze-client/src/test/scala/org/http4s/blaze/client/PoolManagerSuite.scala index d8e15a39c5e..7c96d4a6655 100644 --- a/blaze-client/src/test/scala/org/http4s/blaze/client/PoolManagerSuite.scala +++ b/blaze-client/src/test/scala/org/http4s/blaze/client/PoolManagerSuite.scala @@ -104,13 +104,13 @@ class PoolManagerSuite extends Http4sSuite with AllSyntax { _ <- IO.sleep(timeout + 20.milliseconds) waiting3 <- pool.borrow(key).void.start _ <- pool.release(conn.connection) - result1 <- waiting1.join.void.attempt - result2 <- waiting2.join.void.attempt - result3 <- waiting3.join.void.attempt + result1 <- waiting1.join + result2 <- waiting2.join + result3 <- waiting3.join } yield { - assert(result1 == Left(WaitQueueTimeoutException)) - assert(result2 == Left(WaitQueueTimeoutException)) - assert(result3.isRight) + assertEquals(result1, Outcome.errored[IO, Throwable, Unit](WaitQueueTimeoutException)) + assertEquals(result2, Outcome.errored[IO, Throwable, Unit](WaitQueueTimeoutException)) + assertEquals(result3, Outcome.succeeded[IO, Throwable, Unit](IO.unit)) } } diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/Http1Stage.scala b/blaze-core/src/main/scala/org/http4s/blazecore/Http1Stage.scala index 1cb54a0091b..87e46351f3b 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/Http1Stage.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/Http1Stage.scala @@ -17,13 +17,14 @@ package org.http4s package blazecore -import cats.effect.Effect +import cats.effect.Async import cats.syntax.all._ import fs2._ import fs2.Stream._ import java.nio.ByteBuffer import java.time.Instant +import cats.effect.std.Dispatcher import org.http4s.blaze.http.parser.BaseExceptions.ParserException import org.http4s.blaze.pipeline.{Command, TailStage} import org.http4s.blaze.util.BufferTools @@ -44,7 +45,9 @@ private[http4s] trait Http1Stage[F[_]] { self: TailStage[ByteBuffer] => */ protected implicit def executionContext: ExecutionContext - protected implicit def F: Effect[F] + protected implicit def F: Async[F] + + protected implicit def dispatcher: Dispatcher[F] protected def chunkBufferMaxSize: Int @@ -191,7 +194,7 @@ private[http4s] trait Http1Stage[F[_]] { self: TailStage[ByteBuffer] => @volatile var currentBuffer = buffer // TODO: we need to work trailers into here somehow - val t = F.async[Option[Chunk[Byte]]] { cb => + val t = F.async_[Option[Chunk[Byte]]] { cb => if (!contentComplete()) { def go(): Unit = try { diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/package.scala b/blaze-core/src/main/scala/org/http4s/blazecore/package.scala index 2f96df2a40b..fbe378ab29e 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/package.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/package.scala @@ -20,6 +20,7 @@ import cats.effect.{Resource, Sync} import org.http4s.blaze.util.{Cancelable, TickWheelExecutor} package object blazecore { + private[http4s] def tickWheelResource[F[_]](implicit F: Sync[F]): Resource[F, TickWheelExecutor] = Resource(F.delay { val s = new TickWheelExecutor() diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/BodylessWriter.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/BodylessWriter.scala index 959c5e68dc7..c33d4aa9537 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/BodylessWriter.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/BodylessWriter.scala @@ -33,7 +33,7 @@ import scala.concurrent._ * @param ec an ExecutionContext which will be used to complete operations */ private[http4s] class BodylessWriter[F[_]](pipe: TailStage[ByteBuffer], close: Boolean)(implicit - protected val F: Effect[F], + protected val F: Async[F], protected val ec: ExecutionContext) extends Http1Writer[F] { def writeHeaders(headerWriter: StringWriter): Future[Unit] = diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/CachingChunkWriter.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/CachingChunkWriter.scala index 097d32d900a..eb3fab7b970 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/CachingChunkWriter.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/CachingChunkWriter.scala @@ -22,8 +22,11 @@ import cats.effect._ import fs2._ import java.nio.ByteBuffer import java.nio.charset.StandardCharsets.ISO_8859_1 + +import cats.effect.std.Dispatcher import org.http4s.blaze.pipeline.TailStage import org.http4s.util.StringWriter + import scala.collection.mutable.Buffer import scala.concurrent._ @@ -32,8 +35,9 @@ private[http4s] class CachingChunkWriter[F[_]]( trailer: F[Headers], bufferMaxSize: Int, omitEmptyContentLength: Boolean)(implicit - protected val F: Effect[F], - protected val ec: ExecutionContext) + protected val F: Async[F], + protected val ec: ExecutionContext, + protected val dispatcher: Dispatcher[F]) extends Http1Writer[F] { import ChunkWriter._ @@ -56,7 +60,7 @@ private[http4s] class CachingChunkWriter[F[_]]( size = 0 } - private def toChunk: Chunk[Byte] = Chunk.concatBytes(bodyBuffer.toSeq) + private def toChunk: Chunk[Byte] = Chunk.concat(bodyBuffer.toSeq) override protected def exceptionFlush(): Future[Unit] = { val c = toChunk diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/CachingStaticWriter.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/CachingStaticWriter.scala index 9200d3b180c..460325c2333 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/CachingStaticWriter.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/CachingStaticWriter.scala @@ -29,7 +29,7 @@ import scala.concurrent.{ExecutionContext, Future} private[http4s] class CachingStaticWriter[F[_]]( out: TailStage[ByteBuffer], bufferSize: Int = 8 * 1024)(implicit - protected val F: Effect[F], + protected val F: Async[F], protected val ec: ExecutionContext) extends Http1Writer[F] { @volatile @@ -48,7 +48,7 @@ private[http4s] class CachingStaticWriter[F[_]]( () } - private def toChunk: Chunk[Byte] = Chunk.concatBytes(bodyBuffer.toSeq) + private def toChunk: Chunk[Byte] = Chunk.concat(bodyBuffer.toSeq) private def clear(): Unit = bodyBuffer.clear() diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/ChunkWriter.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/ChunkWriter.scala index 590a710ec4a..6d167d940e3 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/ChunkWriter.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/ChunkWriter.scala @@ -18,14 +18,16 @@ package org.http4s package blazecore package util -import cats.effect.{Effect, IO} +import cats.effect.Async import cats.syntax.all._ import fs2._ import java.nio.ByteBuffer import java.nio.charset.StandardCharsets.ISO_8859_1 + +import cats.effect.std.Dispatcher import org.http4s.blaze.pipeline.TailStage -import org.http4s.internal.unsafeRunAsync import org.http4s.util.StringWriter + import scala.concurrent._ private[util] object ChunkWriter { @@ -45,9 +47,9 @@ private[util] object ChunkWriter { def TransferEncodingChunked = transferEncodingChunkedBuffer.duplicate() def writeTrailer[F[_]](pipe: TailStage[ByteBuffer], trailer: F[Headers])(implicit - F: Effect[F], - ec: ExecutionContext): Future[Boolean] = { - val promise = Promise[Boolean]() + F: Async[F], + ec: ExecutionContext, + dispatcher: Dispatcher[F]): Future[Boolean] = { val f = trailer.map { trailerHeaders => if (!trailerHeaders.isEmpty) { val rr = new StringWriter(256) @@ -59,13 +61,10 @@ private[util] object ChunkWriter { ByteBuffer.wrap(rr.result.getBytes(ISO_8859_1)) } else ChunkEndBuffer } - unsafeRunAsync(f) { - case Right(buffer) => - IO { promise.completeWith(pipe.channelWrite(buffer).map(Function.const(false))); () } - case Left(t) => - IO { promise.failure(t); () } - } - promise.future + for { + buffer <- dispatcher.unsafeToFuture(f) + _ <- pipe.channelWrite(buffer) + } yield false } def writeLength(length: Long): ByteBuffer = { diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/EntityBodyWriter.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/EntityBodyWriter.scala index b28d2975917..4e8f3913ed3 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/EntityBodyWriter.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/EntityBodyWriter.scala @@ -24,7 +24,7 @@ import fs2._ import scala.concurrent._ private[http4s] trait EntityBodyWriter[F[_]] { - implicit protected def F: Effect[F] + implicit protected def F: Async[F] protected val wroteHeader: Promise[Unit] = Promise[Unit]() @@ -58,7 +58,7 @@ private[http4s] trait EntityBodyWriter[F[_]] { * @return the Task which when run will unwind the Process */ def writeEntityBody(p: EntityBody[F]): F[Boolean] = { - val writeBody: F[Unit] = p.through(writePipe).compile.drain + val writeBody: F[Unit] = writePipe(p).compile.drain val writeBodyEnd: F[Boolean] = fromFutureNoShift(F.delay(writeEnd(Chunk.empty))) writeBody *> writeBodyEnd } @@ -68,10 +68,19 @@ private[http4s] trait EntityBodyWriter[F[_]] { * If it errors the error stream becomes the stream, which performs an * exception flush and then the stream fails. */ - private def writePipe: Pipe[F, Byte, Unit] = { s => - val writeStream: Stream[F, Unit] = - s.chunks.evalMap(chunk => fromFutureNoShift(F.delay(writeBodyChunk(chunk, flush = false)))) - val errorStream: Throwable => Stream[F, Unit] = e => + private def writePipe(s: Stream[F, Byte]): Stream[F, INothing] = { + def writeChunk(chunk: Chunk[Byte]): F[Unit] = + fromFutureNoShift(F.delay(writeBodyChunk(chunk, flush = false))) + + val writeStream: Stream[F, INothing] = + s.repeatPull { + _.uncons.flatMap { + case None => Pull.pure(None) + case Some((hd, tl)) => Pull.eval(writeChunk(hd)).as(Some(tl)) + } + } + + val errorStream: Throwable => Stream[F, INothing] = e => Stream .eval(fromFutureNoShift(F.delay(exceptionFlush()))) .flatMap(_ => Stream.raiseError[F](e)) diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/FlushingChunkWriter.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/FlushingChunkWriter.scala index 55e76963c71..991eefebf21 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/FlushingChunkWriter.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/FlushingChunkWriter.scala @@ -18,17 +18,21 @@ package org.http4s package blazecore package util -import cats.effect.Effect +import cats.effect.Async import fs2._ import java.nio.ByteBuffer + +import cats.effect.std.Dispatcher import org.http4s.blaze.pipeline.TailStage import org.http4s.util.StringWriter + import scala.concurrent._ private[http4s] class FlushingChunkWriter[F[_]](pipe: TailStage[ByteBuffer], trailer: F[Headers])( implicit - protected val F: Effect[F], - protected val ec: ExecutionContext) + protected val F: Async[F], + protected val ec: ExecutionContext, + protected val dispatcher: Dispatcher[F]) extends Http1Writer[F] { import ChunkWriter._ diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/Http2Writer.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/Http2Writer.scala index 4da1de7fcd3..c0e8aa47c24 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/Http2Writer.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/Http2Writer.scala @@ -28,7 +28,7 @@ import scala.concurrent._ private[http4s] class Http2Writer[F[_]]( tail: TailStage[StreamFrame], private var headers: Headers, - protected val ec: ExecutionContext)(implicit protected val F: Effect[F]) + protected val ec: ExecutionContext)(implicit protected val F: Async[F]) extends EntityBodyWriter[F] { override protected def writeEnd(chunk: Chunk[Byte]): Future[Boolean] = { val f = diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/IdentityWriter.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/IdentityWriter.scala index b49746fa59f..132752235dc 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/IdentityWriter.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/IdentityWriter.scala @@ -28,7 +28,7 @@ import org.log4s.getLogger import scala.concurrent.{ExecutionContext, Future} private[http4s] class IdentityWriter[F[_]](size: Long, out: TailStage[ByteBuffer])(implicit - protected val F: Effect[F], + protected val F: Async[F], protected val ec: ExecutionContext) extends Http1Writer[F] { diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/util/package.scala b/blaze-core/src/main/scala/org/http4s/blazecore/util/package.scala index 98669334b25..63849e387d5 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/util/package.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/util/package.scala @@ -40,7 +40,7 @@ package object util { case Some(value) => F.fromTry(value) case None => - F.async { cb => + F.async_ { cb => future.onComplete { case Success(a) => cb(Right(a)) case Failure(t) => cb(Left(t)) diff --git a/blaze-core/src/main/scala/org/http4s/blazecore/websocket/Http4sWSStage.scala b/blaze-core/src/main/scala/org/http4s/blazecore/websocket/Http4sWSStage.scala index 73254b1a6cd..e386b3c8812 100644 --- a/blaze-core/src/main/scala/org/http4s/blazecore/websocket/Http4sWSStage.scala +++ b/blaze-core/src/main/scala/org/http4s/blazecore/websocket/Http4sWSStage.scala @@ -19,7 +19,7 @@ package blazecore package websocket import cats.effect._ -import cats.effect.concurrent.Semaphore +import cats.effect.std.{Dispatcher, Semaphore} import cats.syntax.all._ import fs2._ import fs2.concurrent.SignallingRef @@ -27,7 +27,6 @@ import java.util.concurrent.atomic.AtomicBoolean import org.http4s.blaze.pipeline.{LeafBuilder, TailStage, TrunkBuilder} import org.http4s.blaze.pipeline.Command.EOF import org.http4s.blaze.util.Execution.{directec, trampoline} -import org.http4s.internal.unsafeRunAsync import org.http4s.websocket.{ ReservedOpcodeException, UnknownOpcodeException, @@ -45,42 +44,37 @@ import java.net.ProtocolException private[http4s] class Http4sWSStage[F[_]]( ws: WebSocket[F], sentClose: AtomicBoolean, - deadSignal: SignallingRef[F, Boolean] -)(implicit F: ConcurrentEffect[F], val ec: ExecutionContext) + deadSignal: SignallingRef[F, Boolean], + writeSemaphore: Semaphore[F], + dispatcher: Dispatcher[F] +)(implicit F: Async[F]) extends TailStage[WebSocketFrame] { - private[this] val writeSemaphore = F.toIO(Semaphore[F](1L)).unsafeRunSync() - def name: String = "Http4s WebSocket Stage" //////////////////////// Source and Sink generators //////////////////////// - def snk: Pipe[F, WebSocketFrame, Unit] = - _.evalMap { frame => - F.delay(sentClose.get()).flatMap { wasCloseSent => - if (!wasCloseSent) - frame match { - case c: Close => - F.delay(sentClose.compareAndSet(false, true)) - .flatMap(cond => if (cond) writeFrame(c, directec) else F.unit) - case _ => - writeFrame(frame, directec) - } - else - //Close frame has been sent. Send no further data - F.unit - } - } + val isClosed: F[Boolean] = F.delay(sentClose.get()) + val setClosed: F[Boolean] = F.delay(sentClose.compareAndSet(false, true)) + + def evalFrame(frame: WebSocketFrame): F[Unit] = frame match { + case c: Close => setClosed.ifM(writeFrame(c, directec), F.unit) + case _ => writeFrame(frame, directec) + } + + def snkFun(frame: WebSocketFrame): F[Unit] = isClosed.ifM(F.unit, evalFrame(frame)) private[this] def writeFrame(frame: WebSocketFrame, ec: ExecutionContext): F[Unit] = - writeSemaphore.withPermit(F.async[Unit] { cb => - channelWrite(frame).onComplete { - case Success(res) => cb(Right(res)) - case Failure(t) => cb(Left(t)) - }(ec) - }) + writeSemaphore.permit.use { _ => + F.async_[Unit] { cb => + channelWrite(frame).onComplete { + case Success(res) => cb(Right(res)) + case Failure(t) => cb(Left(t)) + }(ec) + } + } private[this] def readFrameTrampoline: F[WebSocketFrame] = - F.async[WebSocketFrame] { cb => + F.async_[WebSocketFrame] { cb => channelRead().onComplete { case Success(ws) => cb(Right(ws)) case Failure(exception) => cb(Left(exception)) @@ -152,21 +146,18 @@ private[http4s] class Http4sWSStage[F[_]]( // Effect to send a close to the other endpoint val sendClose: F[Unit] = F.delay(closePipeline(None)) - val receiveSend: Pipe[F, WebSocketFrame, WebSocketFrame] = + val receiveSent: Stream[F, WebSocketFrame] = ws match { case WebSocketSeparatePipe(send, receive, _) => - incoming => - send.concurrently( - incoming.through(receive).drain - ) //We don't need to terminate if the send stream terminates. + //We don't need to terminate if the send stream terminates. + send.concurrently(receive(inputstream)) case WebSocketCombinedPipe(receiveSend, _) => - receiveSend + receiveSend(inputstream) } val wsStream = - inputstream - .through(receiveSend) - .through(snk) + receiveSent + .evalMap(snkFun) .drain .interruptWhen(deadSignal) .onFinalizeWeak( @@ -176,25 +167,20 @@ private[http4s] class Http4sWSStage[F[_]]( .compile .drain - unsafeRunAsync(wsStream) { - case Left(EOF) => - IO(stageShutdown()) - case Left(t) => - IO(logger.error(t)("Error closing Web Socket")) - case Right(_) => - // Nothing to do here - IO.unit + val result = F.handleErrorWith(wsStream) { + case EOF => + F.delay(stageShutdown()) + case t => + F.delay(logger.error(t)("Error closing Web Socket")) } + dispatcher.unsafeRunAndForget(result) } - // #2735 - // stageShutdown can be called from within an effect, at which point there exists the risk of a deadlock if - // 'unsafeRunSync' is called and all threads are involved in tearing down a connection. override protected def stageShutdown(): Unit = { - F.toIO(deadSignal.set(true)).unsafeRunAsync { - case Left(t) => logger.error(t)("Error setting dead signal") - case Right(_) => () + val fa = F.handleError(deadSignal.set(true)) { t => + logger.error(t)("Error setting dead signal") } + dispatcher.unsafeRunAndForget(fa) super.stageShutdown() } } @@ -202,4 +188,11 @@ private[http4s] class Http4sWSStage[F[_]]( object Http4sWSStage { def bufferingSegment[F[_]](stage: Http4sWSStage[F]): LeafBuilder[WebSocketFrame] = TrunkBuilder(new SerializingStage[WebSocketFrame]).cap(stage) + + def apply[F[_]]( + ws: WebSocket[F], + sentClose: AtomicBoolean, + deadSignal: SignallingRef[F, Boolean], + dispatcher: Dispatcher[F])(implicit F: Async[F]): F[Http4sWSStage[F]] = + Semaphore[F](1L).map(t => new Http4sWSStage(ws, sentClose, deadSignal, t, dispatcher)) } diff --git a/blaze-core/src/test/scala/org/http4s/blazecore/ResponseParser.scala b/blaze-core/src/test/scala/org/http4s/blazecore/ResponseParser.scala index b6da3348388..a15a9fd3a29 100644 --- a/blaze-core/src/test/scala/org/http4s/blazecore/ResponseParser.scala +++ b/blaze-core/src/test/scala/org/http4s/blazecore/ResponseParser.scala @@ -55,7 +55,7 @@ class ResponseParser extends Http1ClientParser { val bp = { val bytes = body.toList.foldLeft(Vector.empty[Chunk[Byte]])((vec, bb) => vec :+ Chunk.byteBuffer(bb)) - new String(Chunk.concatBytes(bytes).toArray, StandardCharsets.ISO_8859_1) + new String(Chunk.concat(bytes).toArray, StandardCharsets.ISO_8859_1) } val headers: Set[Header.Raw] = this.headers diff --git a/blaze-core/src/test/scala/org/http4s/blazecore/TestHead.scala b/blaze-core/src/test/scala/org/http4s/blazecore/TestHead.scala index 4576bb8c180..83ce42b6af0 100644 --- a/blaze-core/src/test/scala/org/http4s/blazecore/TestHead.scala +++ b/blaze-core/src/test/scala/org/http4s/blazecore/TestHead.scala @@ -18,7 +18,8 @@ package org.http4s package blazecore import cats.effect.IO -import fs2.concurrent.Queue +import cats.effect.unsafe.implicits.global +import cats.effect.std.Queue import java.nio.ByteBuffer import org.http4s.blaze.pipeline.HeadStage import org.http4s.blaze.pipeline.Command._ @@ -84,7 +85,7 @@ final class QueueTestHead(queue: Queue[IO, Option[ByteBuffer]]) extends TestHead override def readRequest(size: Int): Future[ByteBuffer] = { val p = Promise[ByteBuffer]() p.completeWith( - queue.dequeue1 + queue.take .flatMap { case Some(bb) => IO.pure(bb) case None => IO.raiseError(EOF) diff --git a/blaze-core/src/test/scala/org/http4s/blazecore/util/DumpingWriter.scala b/blaze-core/src/test/scala/org/http4s/blazecore/util/DumpingWriter.scala index 76f026df392..243a6e73480 100644 --- a/blaze-core/src/test/scala/org/http4s/blazecore/util/DumpingWriter.scala +++ b/blaze-core/src/test/scala/org/http4s/blazecore/util/DumpingWriter.scala @@ -18,7 +18,7 @@ package org.http4s package blazecore package util -import cats.effect.{Effect, IO} +import cats.effect.{Async, IO} import fs2._ import org.http4s.blaze.util.Execution import scala.collection.mutable.Buffer @@ -31,14 +31,14 @@ object DumpingWriter { } } -class DumpingWriter(implicit protected val F: Effect[IO]) extends EntityBodyWriter[IO] { +class DumpingWriter(implicit protected val F: Async[IO]) extends EntityBodyWriter[IO] { override implicit protected def ec: ExecutionContext = Execution.trampoline private val buffer = Buffer[Chunk[Byte]]() def toArray: Array[Byte] = buffer.synchronized { - Chunk.concatBytes(buffer.toSeq).toArray + Chunk.concat(buffer.toSeq).toArray } override protected def writeEnd(chunk: Chunk[Byte]): Future[Boolean] = diff --git a/blaze-core/src/test/scala/org/http4s/blazecore/util/FailingWriter.scala b/blaze-core/src/test/scala/org/http4s/blazecore/util/FailingWriter.scala index 71f47725ce8..a039b4e4a55 100644 --- a/blaze-core/src/test/scala/org/http4s/blazecore/util/FailingWriter.scala +++ b/blaze-core/src/test/scala/org/http4s/blazecore/util/FailingWriter.scala @@ -23,7 +23,7 @@ import fs2._ import org.http4s.blaze.pipeline.Command.EOF import scala.concurrent.{ExecutionContext, Future} -class FailingWriter(implicit protected val F: Effect[IO]) extends EntityBodyWriter[IO] { +class FailingWriter(implicit protected val F: Async[IO]) extends EntityBodyWriter[IO] { override implicit protected val ec: ExecutionContext = scala.concurrent.ExecutionContext.global override protected def writeEnd(chunk: Chunk[Byte]): Future[Boolean] = diff --git a/blaze-core/src/test/scala/org/http4s/blazecore/util/Http1WriterSpec.scala b/blaze-core/src/test/scala/org/http4s/blazecore/util/Http1WriterSpec.scala index 93fdf92aad6..8ceb524e215 100644 --- a/blaze-core/src/test/scala/org/http4s/blazecore/util/Http1WriterSpec.scala +++ b/blaze-core/src/test/scala/org/http4s/blazecore/util/Http1WriterSpec.scala @@ -19,21 +19,21 @@ package blazecore package util import cats.effect._ -import cats.effect.concurrent.Ref +import cats.effect.std.Dispatcher import cats.syntax.all._ -import fs2._ import fs2.Stream._ -import fs2.compression.deflate +import fs2._ +import fs2.compression.{Compression, DeflateParams} import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import org.http4s.blaze.pipeline.{LeafBuilder, TailStage} +import org.http4s.testing.DispatcherIOFixture import org.http4s.util.StringWriter import org.typelevel.ci._ -import scala.concurrent.{ExecutionContext, Future} - -class Http1WriterSpec extends Http4sSuite { - implicit val ec: ExecutionContext = Http4sSuite.TestExecutionContext +import scala.concurrent.ExecutionContext.Implicits._ +import scala.concurrent.Future +class Http1WriterSpec extends Http4sSuite with DispatcherIOFixture { case object Failed extends RuntimeException final def writeEntityBody(p: EntityBody[IO])( @@ -59,163 +59,168 @@ class Http1WriterSpec extends Http4sSuite { } val message = "Hello world!" - val messageBuffer = Chunk.bytes(message.getBytes(StandardCharsets.ISO_8859_1)) + val messageBuffer = Chunk.array(message.getBytes(StandardCharsets.ISO_8859_1)) - final def runNonChunkedTests(name: String, builder: TailStage[ByteBuffer] => Http1Writer[IO]) = { - test(s"$name Write a single emit") { - writeEntityBody(chunk(messageBuffer))(builder) + final def runNonChunkedTests( + name: String, + builder: Dispatcher[IO] => TailStage[ByteBuffer] => Http1Writer[IO]) = { + dispatcher.test(s"$name Write a single emit") { implicit dispatcher => + writeEntityBody(chunk(messageBuffer))(builder(dispatcher)) .assertEquals("Content-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + message) } - test(s"$name Write two emits") { + dispatcher.test(s"$name Write two emits") { implicit dispatcher => val p = chunk(messageBuffer) ++ chunk(messageBuffer) - writeEntityBody(p.covary[IO])(builder) + writeEntityBody(p.covary[IO])(builder(dispatcher)) .assertEquals("Content-Type: text/plain\r\nContent-Length: 24\r\n\r\n" + message + message) } - test(s"$name Write an await") { + dispatcher.test(s"$name Write an await") { implicit dispatcher => val p = eval(IO(messageBuffer)).flatMap(chunk(_).covary[IO]) - writeEntityBody(p)(builder) + writeEntityBody(p)(builder(dispatcher)) .assertEquals("Content-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + message) } - test(s"$name Write two awaits") { + dispatcher.test(s"$name Write two awaits") { implicit dispatcher => val p = eval(IO(messageBuffer)).flatMap(chunk(_).covary[IO]) - writeEntityBody(p ++ p)(builder) + writeEntityBody(p ++ p)(builder(dispatcher)) .assertEquals("Content-Type: text/plain\r\nContent-Length: 24\r\n\r\n" + message + message) } - test(s"$name Write a body that fails and falls back") { + dispatcher.test(s"$name Write a body that fails and falls back") { implicit dispatcher => val p = eval(IO.raiseError(Failed)).handleErrorWith { _ => chunk(messageBuffer) } - writeEntityBody(p)(builder) + writeEntityBody(p)(builder(dispatcher)) .assertEquals("Content-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + message) } - test(s"$name execute cleanup") { + dispatcher.test(s"$name execute cleanup") { implicit dispatcher => (for { clean <- Ref.of[IO, Boolean](false) p = chunk(messageBuffer).covary[IO].onFinalizeWeak(clean.set(true)) - r <- writeEntityBody(p)(builder) + r <- writeEntityBody(p)(builder(dispatcher)) .map(_ == "Content-Type: text/plain\r\nContent-Length: 12\r\n\r\n" + message) c <- clean.get } yield r && c).assert } - test(s"$name Write tasks that repeat eval") { + dispatcher.test(s"$name Write tasks that repeat eval") { implicit dispatcher => val t = { var counter = 2 IO { counter -= 1 - if (counter >= 0) Some(Chunk.bytes("foo".getBytes(StandardCharsets.ISO_8859_1))) + if (counter >= 0) Some(Chunk.array("foo".getBytes(StandardCharsets.ISO_8859_1))) else None } } val p = repeatEval(t).unNoneTerminate.flatMap(chunk(_).covary[IO]) ++ chunk( - Chunk.bytes("bar".getBytes(StandardCharsets.ISO_8859_1))) - writeEntityBody(p)(builder) + Chunk.array("bar".getBytes(StandardCharsets.ISO_8859_1))) + writeEntityBody(p)(builder(dispatcher)) .assertEquals("Content-Type: text/plain\r\nContent-Length: 9\r\n\r\n" + "foofoobar") } } runNonChunkedTests( "CachingChunkWriter", - tail => new CachingChunkWriter[IO](tail, IO.pure(Headers.empty), 1024 * 1024, false)) + implicit dispatcher => + tail => new CachingChunkWriter[IO](tail, IO.pure(Headers.empty), 1024 * 1024, false)) runNonChunkedTests( "CachingStaticWriter", - tail => new CachingChunkWriter[IO](tail, IO.pure(Headers.empty), 1024 * 1024, false)) + implicit dispatcher => + tail => new CachingChunkWriter[IO](tail, IO.pure(Headers.empty), 1024 * 1024, false)) - def builder(tail: TailStage[ByteBuffer]): FlushingChunkWriter[IO] = + def builder(tail: TailStage[ByteBuffer])(implicit D: Dispatcher[IO]): FlushingChunkWriter[IO] = new FlushingChunkWriter[IO](tail, IO.pure(Headers.empty)) - test("FlushingChunkWriter should Write a strict chunk") { + dispatcher.test("FlushingChunkWriter should Write a strict chunk") { implicit d => // n.b. in the scalaz-stream version, we could introspect the // stream, note the lack of effects, and write this with a // Content-Length header. In fs2, this must be chunked. writeEntityBody(chunk(messageBuffer))(builder).assertEquals("""Content-Type: text/plain - |Transfer-Encoding: chunked - | - |c - |Hello world! - |0 - | - |""".stripMargin.replace("\n", "\r\n")) + |Transfer-Encoding: chunked + | + |c + |Hello world! + |0 + | + |""".stripMargin.replace("\n", "\r\n")) } - test("FlushingChunkWriter should Write two strict chunks") { + dispatcher.test("FlushingChunkWriter should Write two strict chunks") { implicit d => val p = chunk(messageBuffer) ++ chunk(messageBuffer) writeEntityBody(p.covary[IO])(builder).assertEquals("""Content-Type: text/plain - |Transfer-Encoding: chunked - | - |c - |Hello world! - |c - |Hello world! - |0 - | - |""".stripMargin.replace("\n", "\r\n")) + |Transfer-Encoding: chunked + | + |c + |Hello world! + |c + |Hello world! + |0 + | + |""".stripMargin.replace("\n", "\r\n")) } - test("FlushingChunkWriter should Write an effectful chunk") { + dispatcher.test("FlushingChunkWriter should Write an effectful chunk") { implicit d => // n.b. in the scalaz-stream version, we could introspect the // stream, note the chunk was followed by halt, and write this // with a Content-Length header. In fs2, this must be chunked. val p = eval(IO(messageBuffer)).flatMap(chunk(_).covary[IO]) writeEntityBody(p)(builder).assertEquals("""Content-Type: text/plain - |Transfer-Encoding: chunked - | - |c - |Hello world! - |0 - | - |""".stripMargin.replace("\n", "\r\n")) + |Transfer-Encoding: chunked + | + |c + |Hello world! + |0 + | + |""".stripMargin.replace("\n", "\r\n")) } - test("FlushingChunkWriter should Write two effectful chunks") { + dispatcher.test("FlushingChunkWriter should Write two effectful chunks") { implicit d => val p = eval(IO(messageBuffer)).flatMap(chunk(_).covary[IO]) writeEntityBody(p ++ p)(builder).assertEquals("""Content-Type: text/plain - |Transfer-Encoding: chunked - | - |c - |Hello world! - |c - |Hello world! - |0 - | - |""".stripMargin.replace("\n", "\r\n")) + |Transfer-Encoding: chunked + | + |c + |Hello world! + |c + |Hello world! + |0 + | + |""".stripMargin.replace("\n", "\r\n")) } - test("FlushingChunkWriter should Elide empty chunks") { + dispatcher.test("FlushingChunkWriter should Elide empty chunks") { implicit d => // n.b. We don't do anything special here. This is a feature of // fs2, but it's important enough we should check it here. val p: Stream[IO, Byte] = chunk(Chunk.empty) ++ chunk(messageBuffer) writeEntityBody(p.covary[IO])(builder).assertEquals("""Content-Type: text/plain - |Transfer-Encoding: chunked - | - |c - |Hello world! - |0 - | - |""".stripMargin.replace("\n", "\r\n")) + |Transfer-Encoding: chunked + | + |c + |Hello world! + |0 + | + |""".stripMargin.replace("\n", "\r\n")) } - test("FlushingChunkWriter should Write a body that fails and falls back") { - val p = eval(IO.raiseError(Failed)).handleErrorWith { _ => - chunk(messageBuffer) - } - writeEntityBody(p)(builder).assertEquals("""Content-Type: text/plain - |Transfer-Encoding: chunked - | - |c - |Hello world! - |0 - | - |""".stripMargin.replace("\n", "\r\n")) + dispatcher.test("FlushingChunkWriter should Write a body that fails and falls back") { + implicit d => + val p = eval(IO.raiseError(Failed)).handleErrorWith { _ => + chunk(messageBuffer) + } + writeEntityBody(p)(builder).assertEquals("""Content-Type: text/plain + |Transfer-Encoding: chunked + | + |c + |Hello world! + |0 + | + |""".stripMargin.replace("\n", "\r\n")) } - test("FlushingChunkWriter should execute cleanup") { + dispatcher.test("FlushingChunkWriter should execute cleanup") { implicit d => (for { clean <- Ref.of[IO, Boolean](false) p = chunk(messageBuffer).onFinalizeWeak(clean.set(true)) @@ -240,8 +245,10 @@ class Http1WriterSpec extends Http4sSuite { // Some tests for the raw unwinding body without HTTP encoding. test("FlushingChunkWriter should write a deflated stream") { val s = eval(IO(messageBuffer)).flatMap(chunk(_).covary[IO]) - val p = s.through(deflate()) - (p.compile.toVector.map(_.toArray), DumpingWriter.dump(s.through(deflate()))) + val p = s.through(Compression[IO].deflate(DeflateParams.DEFAULT)) + ( + p.compile.toVector.map(_.toArray), + DumpingWriter.dump(s.through(Compression[IO].deflate(DeflateParams.DEFAULT)))) .mapN(_ sameElements _) .assert } @@ -261,14 +268,16 @@ class Http1WriterSpec extends Http4sSuite { } test("FlushingChunkWriter should write a deflated resource") { - val p = resource.through(deflate()) - (p.compile.toVector.map(_.toArray), DumpingWriter.dump(resource.through(deflate()))) + val p = resource.through(Compression[IO].deflate(DeflateParams.DEFAULT)) + ( + p.compile.toVector.map(_.toArray), + DumpingWriter.dump(resource.through(Compression[IO].deflate(DeflateParams.DEFAULT)))) .mapN(_ sameElements _) .assert } test("FlushingChunkWriter should must be stack safe") { - val p = repeatEval(IO.async[Byte](_(Right(0.toByte)))).take(300000) + val p = repeatEval(IO.pure[Byte](0.toByte)).take(300000) // The dumping writer is stack safe when using a trampolining EC (new DumpingWriter).writeEntityBody(p).attempt.map(_.isRight).assert @@ -293,7 +302,7 @@ class Http1WriterSpec extends Http4sSuite { } yield w.isLeft && c).assert } - test("FlushingChunkWriter should Write trailer headers") { + dispatcher.test("FlushingChunkWriter should Write trailer headers") { implicit d => def builderWithTrailer(tail: TailStage[ByteBuffer]): FlushingChunkWriter[IO] = new FlushingChunkWriter[IO]( tail, diff --git a/blaze-core/src/test/scala/org/http4s/blazecore/websocket/Http4sWSStageSpec.scala b/blaze-core/src/test/scala/org/http4s/blazecore/websocket/Http4sWSStageSpec.scala index 47ec0849bee..327922c6b05 100644 --- a/blaze-core/src/test/scala/org/http4s/blazecore/websocket/Http4sWSStageSpec.scala +++ b/blaze-core/src/test/scala/org/http4s/blazecore/websocket/Http4sWSStageSpec.scala @@ -18,23 +18,24 @@ package org.http4s.blazecore package websocket import fs2.Stream -import fs2.concurrent.{Queue, SignallingRef} +import fs2.concurrent.SignallingRef import cats.effect.IO import cats.syntax.all._ +import cats.effect.std.{Dispatcher, Queue} import java.util.concurrent.atomic.AtomicBoolean import org.http4s.Http4sSuite import org.http4s.blaze.pipeline.LeafBuilder import org.http4s.websocket.{WebSocketFrame, WebSocketSeparatePipe} import org.http4s.websocket.WebSocketFrame._ import org.http4s.blaze.pipeline.Command +import org.http4s.testing.DispatcherIOFixture import scala.concurrent.ExecutionContext import scala.concurrent.duration._ import scodec.bits.ByteVector -class Http4sWSStageSpec extends Http4sSuite { - implicit val testExecutionContext: ExecutionContext = - ExecutionContext.global +class Http4sWSStageSpec extends Http4sSuite with DispatcherIOFixture { + implicit val testExecutionContext: ExecutionContext = munitExecutionContext class TestWebsocketStage( outQ: Queue[IO, WebSocketFrame], @@ -45,7 +46,7 @@ class Http4sWSStageSpec extends Http4sSuite { Stream .emits(w) .covary[IO] - .through(outQ.enqueue) + .evalMap(outQ.offer) .compile .drain @@ -56,7 +57,8 @@ class Http4sWSStageSpec extends Http4sSuite { head.poll(timeoutSeconds) def pollBackendInbound(timeoutSeconds: Long = 4L): IO[Option[WebSocketFrame]] = - IO.delay(backendInQ.dequeue1.unsafeRunTimed(timeoutSeconds.seconds)) + IO.race(backendInQ.take, IO.sleep(timeoutSeconds.seconds)) + .map(_.fold(Some(_), _ => None)) def pollBatchOutputbound(batchSize: Int, timeoutSeconds: Long = 4L): IO[List[WebSocketFrame]] = head.pollBatch(batchSize, timeoutSeconds) @@ -69,50 +71,57 @@ class Http4sWSStageSpec extends Http4sSuite { } object TestWebsocketStage { - def apply(): IO[TestWebsocketStage] = + def apply()(implicit dispatcher: Dispatcher[IO]): IO[TestWebsocketStage] = for { outQ <- Queue.unbounded[IO, WebSocketFrame] backendInQ <- Queue.unbounded[IO, WebSocketFrame] closeHook = new AtomicBoolean(false) - ws = WebSocketSeparatePipe[IO](outQ.dequeue, backendInQ.enqueue, IO(closeHook.set(true))) + ws = WebSocketSeparatePipe[IO]( + Stream.repeatEval(outQ.take), + _.evalMap(backendInQ.offer), + IO(closeHook.set(true))) deadSignal <- SignallingRef[IO, Boolean](false) wsHead <- WSTestHead() - head = LeafBuilder(new Http4sWSStage[IO](ws, closeHook, deadSignal)).base(wsHead) + http4sWSStage <- Http4sWSStage[IO](ws, closeHook, deadSignal, dispatcher) + head = LeafBuilder(http4sWSStage).base(wsHead) _ <- IO(head.sendInboundCommand(Command.Connected)) } yield new TestWebsocketStage(outQ, head, closeHook, backendInQ) } - test("Http4sWSStage should reply with pong immediately after ping".flaky) { - for { - socket <- TestWebsocketStage() - _ <- socket.sendInbound(Ping()) - p <- socket.pollOutbound(2).map(_.exists(_ == Pong())) - _ <- socket.sendInbound(Close()) - } yield assert(p) + dispatcher.test("Http4sWSStage should reply with pong immediately after ping".flaky) { + implicit d => + for { + socket <- TestWebsocketStage() + _ <- socket.sendInbound(Ping()) + p <- socket.pollOutbound(2).map(_.exists(_ == Pong())) + _ <- socket.sendInbound(Close()) + } yield assert(p) } - test("Http4sWSStage should not write any more frames after close frame sent") { - for { - socket <- TestWebsocketStage() - _ <- socket.sendWSOutbound(Text("hi"), Close(), Text("lol")) - p1 <- socket.pollOutbound().map(_.contains(Text("hi"))) - p2 <- socket.pollOutbound().map(_.contains(Close())) - p3 <- socket.pollOutbound().map(_.isEmpty) - _ <- socket.sendInbound(Close()) - } yield assert(p1 && p2 && p3) + dispatcher.test("Http4sWSStage should not write any more frames after close frame sent") { + implicit d => + for { + socket <- TestWebsocketStage() + _ <- socket.sendWSOutbound(Text("hi"), Close(), Text("lol")) + p1 <- socket.pollOutbound().map(_.contains(Text("hi"))) + p2 <- socket.pollOutbound().map(_.contains(Close())) + p3 <- socket.pollOutbound().map(_.isEmpty) + _ <- socket.sendInbound(Close()) + } yield assert(p1 && p2 && p3) } - test( + dispatcher.test( "Http4sWSStage should send a close frame back and call the on close handler upon receiving a close frame") { - for { - socket <- TestWebsocketStage() - _ <- socket.sendInbound(Close()) - p1 <- socket.pollBatchOutputbound(2, 2).map(_ == List(Close())) - p2 <- socket.wasCloseHookCalled().map(_ == true) - } yield assert(p1 && p2) + implicit d => + for { + socket <- TestWebsocketStage() + _ <- socket.sendInbound(Close()) + p1 <- socket.pollBatchOutputbound(2, 2).map(_ == List(Close())) + p2 <- socket.wasCloseHookCalled().map(_ == true) + } yield assert(p1 && p2) } - test("Http4sWSStage should not send two close frames".flaky) { + dispatcher.test("Http4sWSStage should not send two close frames".flaky) { implicit d => for { socket <- TestWebsocketStage() _ <- socket.sendWSOutbound(Close()) @@ -122,7 +131,7 @@ class Http4sWSStageSpec extends Http4sSuite { } yield assert(p1 && p2) } - test("Http4sWSStage should ignore pong frames") { + dispatcher.test("Http4sWSStage should ignore pong frames") { implicit d => for { socket <- TestWebsocketStage() _ <- socket.sendInbound(Pong()) @@ -131,7 +140,7 @@ class Http4sWSStageSpec extends Http4sSuite { } yield assert(p) } - test("Http4sWSStage should send a ping frames to backend") { + dispatcher.test("Http4sWSStage should send a ping frames to backend") { implicit d => for { socket <- TestWebsocketStage() _ <- socket.sendInbound(Ping()) @@ -143,7 +152,7 @@ class Http4sWSStageSpec extends Http4sSuite { } yield assert(p1 && p2) } - test("Http4sWSStage should send a pong frames to backend") { + dispatcher.test("Http4sWSStage should send a pong frames to backend") { implicit d => for { socket <- TestWebsocketStage() _ <- socket.sendInbound(Pong()) @@ -155,7 +164,7 @@ class Http4sWSStageSpec extends Http4sSuite { } yield assert(p1 && p2) } - test("Http4sWSStage should not fail on pending write request") { + dispatcher.test("Http4sWSStage should not fail on pending write request") { implicit d => for { socket <- TestWebsocketStage() reasonSent = ByteVector(42) diff --git a/blaze-core/src/test/scala/org/http4s/blazecore/websocket/WSTestHead.scala b/blaze-core/src/test/scala/org/http4s/blazecore/websocket/WSTestHead.scala index f19ba4df99a..1c890de37d3 100644 --- a/blaze-core/src/test/scala/org/http4s/blazecore/websocket/WSTestHead.scala +++ b/blaze-core/src/test/scala/org/http4s/blazecore/websocket/WSTestHead.scala @@ -16,15 +16,16 @@ package org.http4s.blazecore.websocket -import cats.effect.{ContextShift, IO, Timer} -import cats.effect.concurrent.Semaphore +import cats.effect.IO +import cats.effect.std.{Queue, Semaphore} import cats.syntax.all._ import fs2.Stream -import fs2.concurrent.Queue import org.http4s.blaze.pipeline.HeadStage import org.http4s.websocket.WebSocketFrame + import scala.concurrent.Future import scala.concurrent.duration._ +import cats.effect.unsafe.implicits.global /** A simple stage to help test websocket requests * @@ -41,17 +42,16 @@ import scala.concurrent.duration._ */ sealed abstract class WSTestHead( inQueue: Queue[IO, WebSocketFrame], - outQueue: Queue[IO, WebSocketFrame])(implicit timer: Timer[IO], cs: ContextShift[IO]) + outQueue: Queue[IO, WebSocketFrame], + writeSemaphore: Semaphore[IO]) extends HeadStage[WebSocketFrame] { - private[this] val writeSemaphore = Semaphore[IO](1L).unsafeRunSync() - /** Block while we put elements into our queue * * @return */ override def readRequest(size: Int): Future[WebSocketFrame] = - inQueue.dequeue1.unsafeToFuture() + inQueue.take.unsafeToFuture() /** Sent downstream from the websocket stage, * put the result in our outqueue, so we may @@ -61,7 +61,7 @@ sealed abstract class WSTestHead( writeSemaphore.tryAcquire .flatMap { case true => - outQueue.enqueue1(data) *> writeSemaphore.release + outQueue.offer(data) *> writeSemaphore.release case false => IO.raiseError(new IllegalStateException("pending write")) } @@ -72,28 +72,41 @@ sealed abstract class WSTestHead( * @param ws */ def put(ws: WebSocketFrame): IO[Unit] = - inQueue.enqueue1(ws) + inQueue.offer(ws) val outStream: Stream[IO, WebSocketFrame] = - outQueue.dequeue + Stream.repeatEval(outQueue.take) /** poll our queue for a value, * timing out after `timeoutSeconds` seconds * runWorker(this); */ def poll(timeoutSeconds: Long): IO[Option[WebSocketFrame]] = - IO.race(timer.sleep(timeoutSeconds.seconds), outQueue.dequeue1) + IO.race(IO.sleep(timeoutSeconds.seconds), outQueue.take) .map { case Left(_) => None case Right(wsFrame) => Some(wsFrame) } - def pollBatch(batchSize: Int, timeoutSeconds: Long): IO[List[WebSocketFrame]] = - outQueue - .dequeueChunk1(batchSize) - .map(_.toList) + def pollBatch(batchSize: Int, timeoutSeconds: Long): IO[List[WebSocketFrame]] = { + def batch(acc: List[WebSocketFrame]): IO[List[WebSocketFrame]] = + if (acc.length == 0) { + outQueue.take.flatMap { frame => + batch(List(frame)) + } + } else if (acc.length < batchSize) { + outQueue.tryTake.flatMap { + case Some(frame) => batch(acc :+ frame) + case None => IO.pure(acc) + } + } else { + IO.pure(acc) + } + + batch(Nil) .timeoutTo(timeoutSeconds.seconds, IO.pure(Nil)) + } override def name: String = "WS test stage" @@ -101,7 +114,7 @@ sealed abstract class WSTestHead( } object WSTestHead { - def apply()(implicit t: Timer[IO], cs: ContextShift[IO]): IO[WSTestHead] = - (Queue.unbounded[IO, WebSocketFrame], Queue.unbounded[IO, WebSocketFrame]) - .mapN(new WSTestHead(_, _) {}) + def apply(): IO[WSTestHead] = + (Queue.unbounded[IO, WebSocketFrame], Queue.unbounded[IO, WebSocketFrame], Semaphore[IO](1L)) + .mapN(new WSTestHead(_, _, _) {}) } diff --git a/blaze-server/src/main/scala/org/http4s/blaze/server/BlazeServerBuilder.scala b/blaze-server/src/main/scala/org/http4s/blaze/server/BlazeServerBuilder.scala index 3a8ffe9759e..2741e40c601 100644 --- a/blaze-server/src/main/scala/org/http4s/blaze/server/BlazeServerBuilder.scala +++ b/blaze-server/src/main/scala/org/http4s/blaze/server/BlazeServerBuilder.scala @@ -18,10 +18,11 @@ package org.http4s package blaze package server +import cats.{Alternative, Applicative} import cats.data.Kleisli -import cats.effect.{ConcurrentEffect, Resource, Sync, Timer} +import cats.effect.{Async, Resource, Sync} +import cats.effect.std.Dispatcher import cats.syntax.all._ -import cats.{Alternative, Applicative} import com.comcast.ip4s.{IpAddress, Port, SocketAddress} import java.io.FileInputStream import java.net.InetSocketAddress @@ -50,7 +51,7 @@ import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future} import scodec.bits.ByteVector -/** BlazeBuilder is the component for the builder pattern aggregating +/** BlazeServerBuilder is the component for the builder pattern aggregating * different components to finally serve requests. * * Variables: @@ -85,7 +86,7 @@ import scodec.bits.ByteVector */ class BlazeServerBuilder[F[_]] private ( socketAddress: InetSocketAddress, - executionContext: ExecutionContext, + executionContextConfig: ExecutionContextConfig, responseHeaderTimeout: Duration, idleTimeout: Duration, connectorPoolSize: Int, @@ -102,7 +103,7 @@ class BlazeServerBuilder[F[_]] private ( banner: immutable.Seq[String], maxConnections: Int, val channelOptions: ChannelOptions -)(implicit protected val F: ConcurrentEffect[F], timer: Timer[F]) +)(implicit protected val F: Async[F]) extends ServerBuilder[F] with BlazeBackendBuilder[Server] { type Self = BlazeServerBuilder[F] @@ -111,7 +112,7 @@ class BlazeServerBuilder[F[_]] private ( private def copy( socketAddress: InetSocketAddress = socketAddress, - executionContext: ExecutionContext = executionContext, + executionContextConfig: ExecutionContextConfig = executionContextConfig, idleTimeout: Duration = idleTimeout, responseHeaderTimeout: Duration = responseHeaderTimeout, connectorPoolSize: Int = connectorPoolSize, @@ -131,7 +132,7 @@ class BlazeServerBuilder[F[_]] private ( ): Self = new BlazeServerBuilder( socketAddress, - executionContext, + executionContextConfig, responseHeaderTimeout, idleTimeout, connectorPoolSize, @@ -201,7 +202,7 @@ class BlazeServerBuilder[F[_]] private ( copy(socketAddress = socketAddress) def withExecutionContext(executionContext: ExecutionContext): BlazeServerBuilder[F] = - copy(executionContext = executionContext) + copy(executionContextConfig = ExecutionContextConfig.ExplicitContext(executionContext)) def withIdleTimeout(idleTimeout: Duration): Self = copy(idleTimeout = idleTimeout) @@ -246,7 +247,8 @@ class BlazeServerBuilder[F[_]] private ( private def pipelineFactory( scheduler: TickWheelExecutor, - engineConfig: Option[(SSLContext, SSLEngine => Unit)] + engineConfig: Option[(SSLContext, SSLEngine => Unit)], + dispatcher: Dispatcher[F] )(conn: SocketConnection): Future[LeafBuilder[ByteBuffer]] = { def requestAttributes(secure: Boolean, optionalSslEngine: Option[SSLEngine]): () => Vault = (conn.local, conn.remote) match { @@ -285,7 +287,7 @@ class BlazeServerBuilder[F[_]] private ( () => Vault.empty } - def http1Stage(secure: Boolean, engine: Option[SSLEngine]) = + def http1Stage(executionContext: ExecutionContext, secure: Boolean, engine: Option[SSLEngine]) = Http1ServerStage( httpApp, requestAttributes(secure = secure, engine), @@ -297,10 +299,11 @@ class BlazeServerBuilder[F[_]] private ( serviceErrorHandler, responseHeaderTimeout, idleTimeout, - scheduler + scheduler, + dispatcher ) - def http2Stage(engine: SSLEngine): ALPNServerSelector = + def http2Stage(executionContext: ExecutionContext, engine: SSLEngine): ALPNServerSelector = ProtocolSelector( engine, httpApp, @@ -312,82 +315,94 @@ class BlazeServerBuilder[F[_]] private ( serviceErrorHandler, responseHeaderTimeout, idleTimeout, - scheduler + scheduler, + dispatcher ) - Future.successful { - engineConfig match { - case Some((ctx, configure)) => - val engine = ctx.createSSLEngine() - engine.setUseClientMode(false) - configure(engine) - - LeafBuilder( - if (isHttp2Enabled) http2Stage(engine) - else http1Stage(secure = true, engine.some) - ).prepend(new SSLStage(engine)) - - case None => - if (isHttp2Enabled) - logger.warn("HTTP/2 support requires TLS. Falling back to HTTP/1.") - LeafBuilder(http1Stage(secure = false, None)) + dispatcher.unsafeToFuture { + executionContextConfig.getExecutionContext[F].map { executionContext => + engineConfig match { + case Some((ctx, configure)) => + val engine = ctx.createSSLEngine() + engine.setUseClientMode(false) + configure(engine) + + LeafBuilder( + if (isHttp2Enabled) http2Stage(executionContext, engine) + else http1Stage(executionContext, secure = true, engine.some) + ).prepend(new SSLStage(engine)) + + case None => + if (isHttp2Enabled) + logger.warn("HTTP/2 support requires TLS. Falling back to HTTP/1.") + LeafBuilder(http1Stage(executionContext, secure = false, None)) + } } } } - def resource: Resource[F, Server] = - tickWheelResource.flatMap { scheduler => - def resolveAddress(address: InetSocketAddress) = - if (address.isUnresolved) new InetSocketAddress(address.getHostName, address.getPort) - else address - - val mkFactory: Resource[F, ServerChannelGroup] = Resource.make(F.delay { - NIO1SocketServerGroup - .fixed( - workerThreads = connectorPoolSize, - bufferSize = bufferSize, - channelOptions = channelOptions, - selectorThreadFactory = selectorThreadFactory, - maxConnections = maxConnections - ) - })(factory => F.delay(factory.closeGroup())) - - def mkServerChannel(factory: ServerChannelGroup): Resource[F, ServerChannel] = - Resource.make( - for { - ctxOpt <- sslConfig.makeContext - engineCfg = ctxOpt.map(ctx => (ctx, sslConfig.configureEngine _)) - address = resolveAddress(socketAddress) - } yield factory.bind(address, pipelineFactory(scheduler, engineCfg)).get - )(serverChannel => F.delay(serverChannel.close())) - - def logStart(server: Server): Resource[F, Unit] = - Resource.eval(F.delay { - Option(banner) - .filter(_.nonEmpty) - .map(_.mkString("\n", "\n", "")) - .foreach(logger.info(_)) - - logger.info( - s"http4s v${Http4sBuildInfo.version} on blaze v${BlazeBuildInfo.version} started at ${server.baseUri}") - }) - - Resource.eval(verifyTimeoutRelations()) >> - mkFactory - .flatMap(mkServerChannel) - .map[F, Server] { serverChannel => - new Server { - val address: InetSocketAddress = - serverChannel.socketAddress - - val isSecure = sslConfig.isSecure - - override def toString: String = - s"BlazeServer($address)" - } - } - .flatTap(logStart) - } + def resource: Resource[F, Server] = { + def resolveAddress(address: InetSocketAddress) = + if (address.isUnresolved) new InetSocketAddress(address.getHostName, address.getPort) + else address + + val mkFactory: Resource[F, ServerChannelGroup] = Resource.make(F.delay { + NIO1SocketServerGroup + .fixed( + workerThreads = connectorPoolSize, + bufferSize = bufferSize, + channelOptions = channelOptions, + selectorThreadFactory = selectorThreadFactory, + maxConnections = maxConnections + ) + })(factory => F.delay(factory.closeGroup())) + + def mkServerChannel( + factory: ServerChannelGroup, + scheduler: TickWheelExecutor, + dispatcher: Dispatcher[F]): Resource[F, ServerChannel] = + Resource.make( + for { + ctxOpt <- sslConfig.makeContext + engineCfg = ctxOpt.map(ctx => (ctx, sslConfig.configureEngine _)) + address = resolveAddress(socketAddress) + } yield factory.bind(address, pipelineFactory(scheduler, engineCfg, dispatcher)).get + )(serverChannel => F.delay(serverChannel.close())) + + def logStart(server: Server): Resource[F, Unit] = + Resource.eval(F.delay { + Option(banner) + .filter(_.nonEmpty) + .map(_.mkString("\n", "\n", "")) + .foreach(logger.info(_)) + + logger.info( + s"http4s v${Http4sBuildInfo.version} on blaze v${BlazeBuildInfo.version} started at ${server.baseUri}") + }) + + for { + // blaze doesn't have graceful shutdowns, which means it may continue to submit effects, + // ever after the server has acknowledged shutdown, so we just need to allocate + dispatcher <- Resource.eval(Dispatcher[F].allocated.map(_._1)) + scheduler <- tickWheelResource + + _ <- Resource.eval(verifyTimeoutRelations()) + + factory <- mkFactory + serverChannel <- mkServerChannel(factory, scheduler, dispatcher) + server = new Server { + val address: InetSocketAddress = + serverChannel.socketAddress + + val isSecure = sslConfig.isSecure + + override def toString: String = + s"BlazeServer($address)" + } + + _ <- logStart(server) + } yield server + } private def verifyTimeoutRelations(): F[Unit] = F.delay { @@ -400,16 +415,13 @@ class BlazeServerBuilder[F[_]] private ( } object BlazeServerBuilder { - @deprecated("Use BlazeServerBuilder.apply with explicit executionContext instead", "0.20.22") - def apply[F[_]](implicit F: ConcurrentEffect[F], timer: Timer[F]): BlazeServerBuilder[F] = - apply(ExecutionContext.global) + def apply[F[_]](executionContext: ExecutionContext)(implicit F: Async[F]): BlazeServerBuilder[F] = + apply[F].withExecutionContext(executionContext) - def apply[F[_]](executionContext: ExecutionContext)(implicit - F: ConcurrentEffect[F], - timer: Timer[F]): BlazeServerBuilder[F] = + def apply[F[_]](implicit F: Async[F]): BlazeServerBuilder[F] = new BlazeServerBuilder( socketAddress = defaults.IPv4SocketAddress, - executionContext = executionContext, + executionContextConfig = ExecutionContextConfig.DefaultContext, responseHeaderTimeout = defaults.ResponseTimeout, idleTimeout = defaults.IdleTimeout, connectorPoolSize = DefaultPoolSize, @@ -526,4 +538,17 @@ object BlazeServerBuilder { case SSLClientAuthMode.Requested => engine.setWantClientAuth(true) case SSLClientAuthMode.NotRequested => () } + + private sealed trait ExecutionContextConfig extends Product with Serializable { + def getExecutionContext[F[_]: Async]: F[ExecutionContext] = this match { + case ExecutionContextConfig.DefaultContext => Async[F].executionContext + case ExecutionContextConfig.ExplicitContext(ec) => ec.pure[F] + } + } + + private object ExecutionContextConfig { + case object DefaultContext extends ExecutionContextConfig + final case class ExplicitContext(executionContext: ExecutionContext) + extends ExecutionContextConfig + } } diff --git a/blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerParser.scala b/blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerParser.scala index b7b70f869c2..82ea6db4e8b 100644 --- a/blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerParser.scala +++ b/blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerParser.scala @@ -28,7 +28,7 @@ import scala.util.Either private[http4s] final class Http1ServerParser[F[_]]( logger: Logger, maxRequestLine: Int, - maxHeadersLen: Int)(implicit F: Effect[F]) + maxHeadersLen: Int)(implicit F: Async[F]) extends blaze.http.parser.Http1ServerParser(maxRequestLine, maxHeadersLen, 2 * 1024) { private var uri: String = _ private var method: String = _ diff --git a/blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerStage.scala b/blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerStage.scala index 38e689627e2..940cfbb61ba 100644 --- a/blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerStage.scala +++ b/blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerStage.scala @@ -18,7 +18,8 @@ package org.http4s package blaze package server -import cats.effect.{CancelToken, Concurrent, ConcurrentEffect, IO, Sync} +import cats.effect.Async +import cats.effect.std.Dispatcher import cats.syntax.all._ import java.nio.ByteBuffer import java.util.concurrent.TimeoutException @@ -31,7 +32,6 @@ import org.http4s.blaze.util.{BufferTools, TickWheelExecutor} import org.http4s.blazecore.util.{BodylessWriter, Http1Writer} import org.http4s.blazecore.{Http1Stage, IdleTimeoutStage} import org.http4s.headers.{Connection, `Content-Length`, `Transfer-Encoding`} -import org.http4s.internal.unsafeRunAsync import org.http4s.server.ServiceErrorHandler import org.http4s.util.StringWriter import org.typelevel.ci._ @@ -52,7 +52,8 @@ private[http4s] object Http1ServerStage { serviceErrorHandler: ServiceErrorHandler[F], responseHeaderTimeout: Duration, idleTimeout: Duration, - scheduler: TickWheelExecutor)(implicit F: ConcurrentEffect[F]): Http1ServerStage[F] = + scheduler: TickWheelExecutor, + dispatcher: Dispatcher[F])(implicit F: Async[F]): Http1ServerStage[F] = if (enableWebSockets) new Http1ServerStage( routes, @@ -64,7 +65,8 @@ private[http4s] object Http1ServerStage { serviceErrorHandler, responseHeaderTimeout, idleTimeout, - scheduler) with WebSocketSupport[F] + scheduler, + dispatcher) with WebSocketSupport[F] else new Http1ServerStage( routes, @@ -76,7 +78,8 @@ private[http4s] object Http1ServerStage { serviceErrorHandler, responseHeaderTimeout, idleTimeout, - scheduler) + scheduler, + dispatcher) } private[blaze] class Http1ServerStage[F[_]]( @@ -89,7 +92,8 @@ private[blaze] class Http1ServerStage[F[_]]( serviceErrorHandler: ServiceErrorHandler[F], responseHeaderTimeout: Duration, idleTimeout: Duration, - scheduler: TickWheelExecutor)(implicit protected val F: ConcurrentEffect[F]) + scheduler: TickWheelExecutor, + val dispatcher: Dispatcher[F])(implicit protected val F: Async[F]) extends Http1Stage[F] with TailStage[ByteBuffer] { // micro-optimization: unwrap the routes and call its .run directly @@ -98,7 +102,7 @@ private[blaze] class Http1ServerStage[F[_]]( // protected by synchronization on `parser` private[this] val parser = new Http1ServerParser[F](logger, maxRequestLineLen, maxHeadersLen) private[this] var isClosed = false - private[this] var cancelToken: Option[CancelToken[F]] = None + private[this] var cancelToken: Option[() => Future[Unit]] = None val name = "Http4sServerStage" @@ -188,22 +192,24 @@ private[blaze] class Http1ServerStage[F[_]]( case Right(req) => executionContext.execute(new Runnable { def run(): Unit = { - val action = Sync[F] - .defer(raceTimeout(req)) + val action = raceTimeout(req) .recoverWith(serviceErrorHandler(req)) .flatMap(resp => F.delay(renderResponse(req, resp, cleanup))) - - val theCancelToken = Some( - F.runCancelable(action) { - case Right(()) => IO.unit + .attempt + .flatMap { + case Right(_) => F.unit case Left(t) => - IO(logger.error(t)(s"Error running request: $req")).attempt *> IO( + F.delay(logger.error(t)(s"Error running request: $req")).attempt *> F.delay( closeConnection()) - }.unsafeRunSync()) + } + + val token = Some(dispatcher.unsafeToFutureCancelable(action)._2) parser.synchronized { - cancelToken = theCancelToken + cancelToken = token } + + () } }) case Left((e, protocol)) => @@ -271,28 +277,36 @@ private[blaze] class Http1ServerStage[F[_]]( false) } - unsafeRunAsync(bodyEncoder.write(rr, resp.body).recover { case EOF => true }) { - case Right(requireClose) => - if (closeOnFinish || requireClose) { - logger.trace("Request/route requested closing connection.") - IO(closeConnection()) - } else - IO { - bodyCleanup().onComplete { - case s @ Success(_) => // Serve another request - parser.reset() - handleReqRead(s) - - case Failure(EOF) => closeConnection() - - case Failure(t) => fatalError(t, "Failure in body cleanup") - }(trampoline) - } + // TODO: pool shifting: https://github.com/http4s/http4s/blob/main/core/src/main/scala/org/http4s/internal/package.scala#L45 + val fa = bodyEncoder + .write(rr, resp.body) + .recover { case EOF => true } + .attempt + .flatMap { + case Right(requireClose) => + if (closeOnFinish || requireClose) { + logger.trace("Request/route requested closing connection.") + F.delay(closeConnection()) + } else + F.delay { + bodyCleanup().onComplete { + case s @ Success(_) => // Serve another request + parser.reset() + handleReqRead(s) + + case Failure(EOF) => closeConnection() + + case Failure(t) => fatalError(t, "Failure in body cleanup") + }(trampoline) + } + case Left(t) => + logger.error(t)("Error writing body") + F.delay(closeConnection()) + } - case Left(t) => - logger.error(t)("Error writing body") - IO(closeConnection()) - } + dispatcher.unsafeRunAndForget(fa) + + () } private def closeConnection(): Unit = { @@ -312,12 +326,12 @@ private[blaze] class Http1ServerStage[F[_]]( } private def cancel(): Unit = - cancelToken.foreach { token => - F.runAsync(token) { - case Right(_) => IO(logger.debug("Canceled request")) - case Left(t) => IO(logger.error(t)("Error canceling request")) - }.unsafeRunSync() - } + cancelToken.foreach(_().onComplete { + case Success(_) => + () + case Failure(t) => + logger.warn(t)(s"Error canceling request. No request details are available.") + }) final protected def badMessage( debugMessage: String, @@ -348,10 +362,12 @@ private[blaze] class Http1ServerStage[F[_]]( private[this] val raceTimeout: Request[F] => F[Response[F]] = responseHeaderTimeout match { case finite: FiniteDuration => - val timeoutResponse = Concurrent[F].cancelable[Response[F]] { callback => - val cancellable = - scheduler.schedule(() => callback(Right(Response.timeout[F])), executionContext, finite) - Sync[F].delay(cancellable.cancel()) + val timeoutResponse = F.async[Response[F]] { cb => + F.delay { + val cancellable = + scheduler.schedule(() => cb(Right(Response.timeout[F])), executionContext, finite) + Some(F.delay(cancellable.cancel())) + } } req => F.race(runApp(req), timeoutResponse).map(_.merge) case _ => diff --git a/blaze-server/src/main/scala/org/http4s/blaze/server/Http2NodeStage.scala b/blaze-server/src/main/scala/org/http4s/blaze/server/Http2NodeStage.scala index c188c17a522..ccd2695f662 100644 --- a/blaze-server/src/main/scala/org/http4s/blaze/server/Http2NodeStage.scala +++ b/blaze-server/src/main/scala/org/http4s/blaze/server/Http2NodeStage.scala @@ -18,7 +18,8 @@ package org.http4s package blaze package server -import cats.effect.{ConcurrentEffect, IO, Sync, Timer} +import cats.effect.Async +import cats.effect.std.Dispatcher import cats.syntax.all._ import fs2.Stream._ import fs2._ @@ -47,7 +48,8 @@ private class Http2NodeStage[F[_]]( serviceErrorHandler: ServiceErrorHandler[F], responseHeaderTimeout: Duration, idleTimeout: Duration, - scheduler: TickWheelExecutor)(implicit F: ConcurrentEffect[F], timer: Timer[F]) + scheduler: TickWheelExecutor, + dispatcher: Dispatcher[F])(implicit F: Async[F]) extends TailStage[StreamFrame] { // micro-optimization: unwrap the service and call its .run directly private[this] val runApp = httpApp.run @@ -101,49 +103,53 @@ private class Http2NodeStage[F[_]]( var bytesRead = 0L val t = F.async[Option[Chunk[Byte]]] { cb => - if (complete) cb(End) - else - channelRead(timeout = timeout).onComplete { - case Success(DataFrame(last, bytes)) => - complete = last - bytesRead += bytes.remaining() - - // Check length: invalid length is a stream error of type PROTOCOL_ERROR - // https://tools.ietf.org/html/draft-ietf-httpbis-http2-17#section-8.1.2 -> 8.2.1.6 - if (complete && maxlen > 0 && bytesRead != maxlen) { - val msg = s"Entity too small. Expected $maxlen, received $bytesRead" + F.delay { + if (complete) cb(End) + else + channelRead(timeout = timeout).onComplete { + case Success(DataFrame(last, bytes)) => + complete = last + bytesRead += bytes.remaining() + + // Check length: invalid length is a stream error of type PROTOCOL_ERROR + // https://tools.ietf.org/html/draft-ietf-httpbis-http2-17#section-8.1.2 -> 8.2.1.6 + if (complete && maxlen > 0 && bytesRead != maxlen) { + val msg = s"Entity too small. Expected $maxlen, received $bytesRead" + val e = Http2Exception.PROTOCOL_ERROR.rst(streamId, msg) + closePipeline(Some(e)) + cb(Either.left(InvalidBodyException(msg))) + } else if (maxlen > 0 && bytesRead > maxlen) { + val msg = s"Entity too large. Expected $maxlen, received bytesRead" + val e = Http2Exception.PROTOCOL_ERROR.rst(streamId, msg) + closePipeline(Some(e)) + cb(Either.left(InvalidBodyException(msg))) + } else cb(Either.right(Some(Chunk.array(bytes.array)))) + + case Success(HeadersFrame(_, true, ts)) => + logger.warn("Discarding trailers: " + ts) + cb(Either.right(Some(Chunk.empty))) + + case Success(other) => // This should cover it + val msg = "Received invalid frame while accumulating body: " + other + logger.info(msg) val e = Http2Exception.PROTOCOL_ERROR.rst(streamId, msg) closePipeline(Some(e)) cb(Either.left(InvalidBodyException(msg))) - } else if (maxlen > 0 && bytesRead > maxlen) { - val msg = s"Entity too large. Expected $maxlen, received bytesRead" - val e = Http2Exception.PROTOCOL_ERROR.rst(streamId, msg) + + case Failure(Cmd.EOF) => + logger.debug("EOF while accumulating body") + cb(Either.left(InvalidBodyException("Received premature EOF."))) + closePipeline(None) + + case Failure(t) => + logger.error(t)("Error in getBody().") + val e = Http2Exception.INTERNAL_ERROR.rst(streamId, "Failed to read body") + cb(Either.left(e)) closePipeline(Some(e)) - cb(Either.left(InvalidBodyException(msg))) - } else cb(Either.right(Some(Chunk.bytes(bytes.array)))) - - case Success(HeadersFrame(_, true, ts)) => - logger.warn("Discarding trailers: " + ts) - cb(Either.right(Some(Chunk.empty))) - - case Success(other) => // This should cover it - val msg = "Received invalid frame while accumulating body: " + other - logger.info(msg) - val e = Http2Exception.PROTOCOL_ERROR.rst(streamId, msg) - closePipeline(Some(e)) - cb(Either.left(InvalidBodyException(msg))) - - case Failure(Cmd.EOF) => - logger.debug("EOF while accumulating body") - cb(Either.left(InvalidBodyException("Received premature EOF."))) - closePipeline(None) + } - case Failure(t) => - logger.error(t)("Error in getBody().") - val e = Http2Exception.INTERNAL_ERROR.rst(streamId, "Failed to read body") - cb(Either.left(e)) - closePipeline(Some(e)) - } + None + } } repeatEval(t).unNoneTerminate.flatMap(chunk(_).covary[F]) @@ -224,17 +230,22 @@ private class Http2NodeStage[F[_]]( val req = Request(method, path, HttpVersion.`HTTP/2.0`, hs, body, attributes()) executionContext.execute(new Runnable { def run(): Unit = { - val action = Sync[F] + val action = F .defer(raceTimeout(req)) .recoverWith(serviceErrorHandler(req)) - .flatMap(renderResponse) + .flatMap(renderResponse(_)) - F.runAsync(action) { - case Right(()) => IO.unit + val fa = action.attempt.flatMap { + case Right(_) => F.unit case Left(t) => - IO(logger.error(t)(s"Error running request: $req")).attempt *> IO(closePipeline(None)) + F.delay(logger.error(t)(s"Error running request: $req")).attempt *> F.delay( + closePipeline(None)) } - }.unsafeRunSync() + + dispatcher.unsafeRunSync(fa) + + () + } }) } } @@ -263,7 +274,7 @@ private class Http2NodeStage[F[_]]( private[this] val raceTimeout: Request[F] => F[Response[F]] = responseHeaderTimeout match { case finite: FiniteDuration => - val timeoutResponse = timer.sleep(finite).as(Response.timeout[F]) + val timeoutResponse = F.sleep(finite).as(Response.timeout[F]) req => F.race(runApp(req), timeoutResponse).map(_.merge) case _ => runApp diff --git a/blaze-server/src/main/scala/org/http4s/blaze/server/ProtocolSelector.scala b/blaze-server/src/main/scala/org/http4s/blaze/server/ProtocolSelector.scala index 714546e70d4..034cbb2dd94 100644 --- a/blaze-server/src/main/scala/org/http4s/blaze/server/ProtocolSelector.scala +++ b/blaze-server/src/main/scala/org/http4s/blaze/server/ProtocolSelector.scala @@ -18,7 +18,8 @@ package org.http4s package blaze package server -import cats.effect.{ConcurrentEffect, Timer} +import cats.effect.Async +import cats.effect.std.Dispatcher import java.nio.ByteBuffer import javax.net.ssl.SSLEngine import org.http4s.blaze.http.http2.server.{ALPNServerSelector, ServerPriorKnowledgeHandshaker} @@ -43,9 +44,8 @@ private[http4s] object ProtocolSelector { serviceErrorHandler: ServiceErrorHandler[F], responseHeaderTimeout: Duration, idleTimeout: Duration, - scheduler: TickWheelExecutor)(implicit - F: ConcurrentEffect[F], - timer: Timer[F]): ALPNServerSelector = { + scheduler: TickWheelExecutor, + dispatcher: Dispatcher[F])(implicit F: Async[F]): ALPNServerSelector = { def http2Stage(): TailStage[ByteBuffer] = { val newNode = { (streamId: Int) => LeafBuilder( @@ -58,7 +58,8 @@ private[http4s] object ProtocolSelector { serviceErrorHandler, responseHeaderTimeout, idleTimeout, - scheduler + scheduler, + dispatcher )) } @@ -85,7 +86,8 @@ private[http4s] object ProtocolSelector { serviceErrorHandler, responseHeaderTimeout, idleTimeout, - scheduler + scheduler, + dispatcher ) def preference(protos: Set[String]): String = diff --git a/blaze-server/src/main/scala/org/http4s/blaze/server/WebSocketSupport.scala b/blaze-server/src/main/scala/org/http4s/blaze/server/WebSocketSupport.scala index cce84e8e47e..2b7b297239c 100644 --- a/blaze-server/src/main/scala/org/http4s/blaze/server/WebSocketSupport.scala +++ b/blaze-server/src/main/scala/org/http4s/blaze/server/WebSocketSupport.scala @@ -26,14 +26,16 @@ import org.http4s._ import org.http4s.blaze.pipeline.LeafBuilder import org.http4s.blazecore.websocket.Http4sWSStage import org.http4s.headers._ -import org.http4s.internal.unsafeRunAsync import org.http4s.websocket.WebSocketHandshake import org.typelevel.ci._ import scala.concurrent.Future import scala.util.{Failure, Success} +import cats.effect.std.{Dispatcher, Semaphore} private[http4s] trait WebSocketSupport[F[_]] extends Http1ServerStage[F] { - protected implicit val F: ConcurrentEffect[F] + protected implicit val F: Async[F] + + implicit val dispatcher: Dispatcher[F] override protected def renderResponse( req: Request[F], @@ -50,19 +52,24 @@ private[http4s] trait WebSocketSupport[F[_]] extends Http1ServerStage[F] { WebSocketHandshake.serverHandshake(hdrs) match { case Left((code, msg)) => logger.info(s"Invalid handshake $code, $msg") - unsafeRunAsync { + val fa = wsContext.failureResponse .map( _.withHeaders( Connection(ci"close"), "Sec-WebSocket-Version" -> "13" )) - } { - case Right(resp) => - IO(super.renderResponse(req, resp, cleanup)) - case Left(_) => - IO.unit - } + .attempt + .flatMap { + case Right(resp) => + F.delay(super.renderResponse(req, resp, cleanup)) + case Left(_) => + F.unit + } + + dispatcher.unsafeRunAndForget(fa) + + () case Right(hdrs) => // Successful handshake val sb = new StringBuilder @@ -83,10 +90,19 @@ private[http4s] trait WebSocketSupport[F[_]] extends Http1ServerStage[F] { case Success(_) => logger.debug("Switching pipeline segments for websocket") - val deadSignal = F.toIO(SignallingRef[F, Boolean](false)).unsafeRunSync() + val deadSignal = dispatcher.unsafeRunSync(SignallingRef[F, Boolean](false)) + val writeSemaphore = dispatcher.unsafeRunSync(Semaphore[F](1L)) val sentClose = new AtomicBoolean(false) val segment = - LeafBuilder(new Http4sWSStage[F](wsContext.webSocket, sentClose, deadSignal)) + LeafBuilder( + new Http4sWSStage[F]( + wsContext.webSocket, + sentClose, + deadSignal, + writeSemaphore, + dispatcher + ) + ) // TODO: there is a constructor .prepend(new WSFrameAggregator) .prepend(new WebSocketDecoder) diff --git a/blaze-server/src/test/scala/org/http4s/blaze/server/BlazeServerMtlsSpec.scala b/blaze-server/src/test/scala/org/http4s/blaze/server/BlazeServerMtlsSpec.scala index 2350ffaa1e3..a956a0534a7 100644 --- a/blaze-server/src/test/scala/org/http4s/blaze/server/BlazeServerMtlsSpec.scala +++ b/blaze-server/src/test/scala/org/http4s/blaze/server/BlazeServerMtlsSpec.scala @@ -16,8 +16,8 @@ package org.http4s.blaze.server -import cats.effect.{ContextShift, IO, Resource} -import fs2.io.tls.TLSParameters +import cats.effect.{IO, Resource} +import fs2.io.net.tls.TLSParameters import java.net.URL import java.nio.charset.StandardCharsets import java.security.KeyStore @@ -34,7 +34,6 @@ import scala.util.Try /** Test cases for mTLS support in blaze server */ class BlazeServerMtlsSpec extends Http4sSuite { - { val hostnameVerifier: HostnameVerifier = new HostnameVerifier { override def verify(s: String, sslSession: SSLSession): Boolean = true @@ -43,7 +42,6 @@ class BlazeServerMtlsSpec extends Http4sSuite { //For test cases, don't do any host name verification. Certificates are self-signed and not available to all hosts HttpsURLConnection.setDefaultHostnameVerifier(hostnameVerifier) } - implicit val contextShift: ContextShift[IO] = Http4sSuite.TestContextShift def builder: BlazeServerBuilder[IO] = BlazeServerBuilder[IO](global) diff --git a/blaze-server/src/test/scala/org/http4s/blaze/server/BlazeServerSuite.scala b/blaze-server/src/test/scala/org/http4s/blaze/server/BlazeServerSuite.scala index 538ef9ce721..e5858438845 100644 --- a/blaze-server/src/test/scala/org/http4s/blaze/server/BlazeServerSuite.scala +++ b/blaze-server/src/test/scala/org/http4s/blaze/server/BlazeServerSuite.scala @@ -19,20 +19,51 @@ package blaze package server import cats.effect._ +import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} import cats.syntax.all._ import java.net.{HttpURLConnection, URL} import java.nio.charset.StandardCharsets -import munit.TestOptions +import java.util.concurrent.{ScheduledExecutorService, ScheduledThreadPoolExecutor, TimeUnit} import org.http4s.blaze.channel.ChannelOptions import org.http4s.dsl.io._ -import org.http4s.multipart.Multipart -import org.http4s.server.Server -import scala.concurrent.ExecutionContext.global +import org.http4s.internal.threads._ import scala.concurrent.duration._ import scala.io.Source +import org.http4s.multipart.Multipart +import org.http4s.server.Server +import scala.concurrent.ExecutionContext, ExecutionContext.global +import munit.TestOptions class BlazeServerSuite extends Http4sSuite { - implicit val contextShift: ContextShift[IO] = Http4sSuite.TestContextShift + + override implicit val ioRuntime: IORuntime = { + val TestScheduler: ScheduledExecutorService = { + val s = + new ScheduledThreadPoolExecutor( + 2, + threadFactory(i => s"blaze-server-suite-scheduler-$i", true)) + s.setKeepAliveTime(10L, TimeUnit.SECONDS) + s.allowCoreThreadTimeOut(true) + s + } + + val blockingPool = newBlockingPool("blaze-server-suite-blocking") + val computePool = newDaemonPool("blaze-server-suite-compute", timeout = true) + val scheduledExecutor = TestScheduler + IORuntime.apply( + ExecutionContext.fromExecutor(computePool), + ExecutionContext.fromExecutor(blockingPool), + Scheduler.fromScheduledExecutor(scheduledExecutor), + () => { + blockingPool.shutdown() + computePool.shutdown() + scheduledExecutor.shutdown() + }, + IORuntimeConfig() + ) + } + + override def afterAll(): Unit = ioRuntime.shutdown() def builder = BlazeServerBuilder[IO](global) @@ -72,26 +103,25 @@ class BlazeServerSuite extends Http4sSuite { (_: TestOptions, _: Server) => IO.unit, (_: Server) => IO.sleep(100.milliseconds) *> IO.unit) - // This should be in IO and shifted but I'm tired of fighting this. - def get(server: Server, path: String): IO[String] = IO { + def get(server: Server, path: String): IO[String] = IO.blocking { Source .fromURL(new URL(s"http://127.0.0.1:${server.address.getPort}$path")) .getLines() .mkString } - // This should be in IO and shifted but I'm tired of fighting this. def getStatus(server: Server, path: String): IO[Status] = { val url = new URL(s"http://127.0.0.1:${server.address.getPort}$path") for { - conn <- IO(url.openConnection().asInstanceOf[HttpURLConnection]) + conn <- IO.blocking(url.openConnection().asInstanceOf[HttpURLConnection]) _ = conn.setRequestMethod("GET") - status <- IO.fromEither(Status.fromInt(conn.getResponseCode())) + status <- IO + .blocking(conn.getResponseCode()) + .flatMap(code => IO.fromEither(Status.fromInt(code))) } yield status } - // This too - def post(server: Server, path: String, body: String): IO[String] = IO { + def post(server: Server, path: String, body: String): IO[String] = IO.blocking { val url = new URL(s"http://127.0.0.1:${server.address.getPort}$path") val conn = url.openConnection().asInstanceOf[HttpURLConnection] val bytes = body.getBytes(StandardCharsets.UTF_8) @@ -102,13 +132,12 @@ class BlazeServerSuite extends Http4sSuite { Source.fromInputStream(conn.getInputStream, StandardCharsets.UTF_8.name).getLines().mkString } - // This too def postChunkedMultipart( server: Server, path: String, boundary: String, body: String): IO[String] = - IO { + IO.blocking { val url = new URL(s"http://127.0.0.1:${server.address.getPort}$path") val conn = url.openConnection().asInstanceOf[HttpURLConnection] val bytes = body.getBytes(StandardCharsets.UTF_8) @@ -121,11 +150,11 @@ class BlazeServerSuite extends Http4sSuite { } blazeServer.test("route requests on the service executor".flaky) { server => - get(server, "/thread/routing").map(_.startsWith("http4s-suite-")).assert + get(server, "/thread/routing").map(_.startsWith("blaze-server-suite-compute-")).assert } blazeServer.test("execute the service task on the service executor") { server => - get(server, "/thread/effect").map(_.startsWith("http4s-suite-")).assert + get(server, "/thread/effect").map(_.startsWith("blaze-server-suite-compute-")).assert } blazeServer.test("be able to echo its input") { server => diff --git a/blaze-server/src/test/scala/org/http4s/blaze/server/Http1ServerStageSpec.scala b/blaze-server/src/test/scala/org/http4s/blaze/server/Http1ServerStageSpec.scala index 05660fcf95c..582a87870a8 100644 --- a/blaze-server/src/test/scala/org/http4s/blaze/server/Http1ServerStageSpec.scala +++ b/blaze-server/src/test/scala/org/http4s/blaze/server/Http1ServerStageSpec.scala @@ -19,12 +19,13 @@ package blaze package server import cats.data.Kleisli -import cats.effect._ -import cats.effect.concurrent.Deferred import cats.syntax.all._ +import cats.effect._ +import cats.effect.kernel.Deferred +import cats.effect.std.Dispatcher import java.nio.ByteBuffer import java.nio.charset.StandardCharsets -import org.http4s.blaze.pipeline.Command.Connected +import org.http4s.blaze.pipeline.Command.{Connected, Disconnected} import org.http4s.blaze.util.TickWheelExecutor import org.http4s.blazecore.{ResponseParser, SeqTestHead} import org.http4s.dsl.io._ @@ -38,12 +39,27 @@ import scala.concurrent.ExecutionContext import scala.concurrent.duration._ class Http1ServerStageSpec extends Http4sSuite { - implicit val ec: ExecutionContext = Http4sSuite.TestExecutionContext - val tickWheel = ResourceFixture(Resource.make(IO.delay(new TickWheelExecutor())) { twe => + val fixture = ResourceFixture(Resource.make(IO.delay(new TickWheelExecutor())) { twe => IO.delay(twe.shutdown()) }) + // todo replace with DispatcherIOFixture + val dispatcher = new Fixture[Dispatcher[IO]]("dispatcher") { + + private var d: Dispatcher[IO] = null + private var shutdown: IO[Unit] = null + def apply() = d + override def beforeAll(): Unit = { + val dispatcherAndShutdown = Dispatcher[IO].allocated.unsafeRunSync() + shutdown = dispatcherAndShutdown._2 + d = dispatcherAndShutdown._1 + } + override def afterAll(): Unit = + shutdown.unsafeRunSync() + } + override def munitFixtures = List(dispatcher) + def makeString(b: ByteBuffer): String = { val p = b.position() val a = new Array[Byte](b.remaining()) @@ -60,7 +76,7 @@ class Http1ServerStageSpec extends Http4sSuite { } def runRequest( - tickWheel: TickWheelExecutor, + tw: TickWheelExecutor, req: Seq[String], httpApp: HttpApp[IO], maxReqLine: Int = 4 * 1024, @@ -78,7 +94,8 @@ class Http1ServerStageSpec extends Http4sSuite { silentErrorHandler, 30.seconds, 30.seconds, - tickWheel + tw, + dispatcher() ) pipeline.LeafBuilder(httpStage).base(head) @@ -94,7 +111,7 @@ class Http1ServerStageSpec extends Http4sSuite { } .orNotFound - tickWheel.test("Http1ServerStage: Invalid Lengths should fail on too long of a request line") { + fixture.test("Http1ServerStage: Invalid Lengths should fail on too long of a request line") { tickwheel => runRequest(tickwheel, Seq(req), routes, maxReqLine = 1).result.map { buff => val str = StandardCharsets.ISO_8859_1.decode(buff.duplicate()).toString @@ -103,7 +120,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test("Http1ServerStage: Invalid Lengths should fail on too long of a header") { + fixture.test("Http1ServerStage: Invalid Lengths should fail on too long of a header") { tickwheel => (runRequest(tickwheel, Seq(req), routes, maxHeaders = 1).result).map { buff => val str = StandardCharsets.ISO_8859_1.decode(buff.duplicate()).toString @@ -115,7 +132,7 @@ class Http1ServerStageSpec extends Http4sSuite { ServerTestRoutes.testRequestResults.zipWithIndex.foreach { case ((req, (status, headers, resp)), i) => if (i == 7 || i == 8) // Awful temporary hack - tickWheel.test( + fixture.test( s"Http1ServerStage: Common responses should Run request $i Run request: --------\n${req .split("\r\n\r\n")(0)}\n") { tw => runRequest(tw, Seq(req), ServerTestRoutes()).result @@ -124,7 +141,7 @@ class Http1ServerStageSpec extends Http4sSuite { } else - tickWheel.test( + fixture.test( s"Http1ServerStage: Common responses should Run request $i Run request: --------\n${req .split("\r\n\r\n")(0)}\n") { tw => runRequest(tw, Seq(req), ServerTestRoutes()).result @@ -155,44 +172,42 @@ class Http1ServerStageSpec extends Http4sSuite { (s, close, r) } - tickWheel.test("Http1ServerStage: Errors should Deal with synchronous errors") { tw => + fixture.test("Http1ServerStage: Errors should Deal with synchronous errors") { tw => val path = "GET /sync HTTP/1.1\r\nConnection:keep-alive\r\n\r\n" runError(tw, path).map { case (s, c, _) => assert(s == InternalServerError && c) } } - tickWheel.test("Http1ServerStage: Errors should Call toHttpResponse on synchronous errors") { - tw => - val path = "GET /sync/422 HTTP/1.1\r\nConnection:keep-alive\r\n\r\n" - runError(tw, path).map { case (s, c, _) => - assert(s == UnprocessableEntity && !c) - } + fixture.test("Http1ServerStage: Errors should Call toHttpResponse on synchronous errors") { tw => + val path = "GET /sync/422 HTTP/1.1\r\nConnection:keep-alive\r\n\r\n" + runError(tw, path).map { case (s, c, _) => + assert(s == UnprocessableEntity && !c) + } } - tickWheel.test("Http1ServerStage: Errors should Deal with asynchronous errors") { tw => + fixture.test("Http1ServerStage: Errors should Deal with asynchronous errors") { tw => val path = "GET /async HTTP/1.1\r\nConnection:keep-alive\r\n\r\n" runError(tw, path).map { case (s, c, _) => assert(s == InternalServerError && c) } } - tickWheel.test("Http1ServerStage: Errors should Call toHttpResponse on asynchronous errors") { - tw => - val path = "GET /async/422 HTTP/1.1\r\nConnection:keep-alive\r\n\r\n" - runError(tw, path).map { case (s, c, _) => - assert(s == UnprocessableEntity && !c) - } + fixture.test("Http1ServerStage: Errors should Call toHttpResponse on asynchronous errors") { tw => + val path = "GET /async/422 HTTP/1.1\r\nConnection:keep-alive\r\n\r\n" + runError(tw, path).map { case (s, c, _) => + assert(s == UnprocessableEntity && !c) + } } - tickWheel.test("Http1ServerStage: Errors should Handle parse error") { tw => + fixture.test("Http1ServerStage: Errors should Handle parse error") { tw => val path = "THIS\u0000IS\u0000NOT\u0000HTTP" runError(tw, path).map { case (s, c, _) => assert(s == BadRequest && c) } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Do not send `Transfer-Encoding: identity` response") { tw => val routes = HttpRoutes .of[IO] { case _ => @@ -217,7 +232,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Do not send an entity or entity-headers for a status that doesn't permit it") { tw => val routes: HttpApp[IO] = HttpRoutes @@ -241,7 +256,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test("Http1ServerStage: routes should Add a date header") { tw => + fixture.test("Http1ServerStage: routes should Add a date header") { tw => val routes = HttpRoutes .of[IO] { case req => IO.pure(Response(body = req.body)) @@ -258,7 +273,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test("Http1ServerStage: routes should Honor an explicitly added date header") { tw => + fixture.test("Http1ServerStage: routes should Honor an explicitly added date header") { tw => val dateHeader = Date(HttpDate.Epoch) val routes = HttpRoutes .of[IO] { case req => @@ -278,7 +293,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Handle routes that echos full request body for non-chunked") { tw => val routes = HttpRoutes @@ -299,7 +314,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Handle routes that consumes the full request body for non-chunked") { tw => val routes = HttpRoutes @@ -329,7 +344,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Maintain the connection if the body is ignored but was already read to completion by the Http1Stage") { tw => val routes = HttpRoutes @@ -353,7 +368,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Drop the connection if the body is ignored and was not read to completion by the Http1Stage") { tw => val routes = HttpRoutes @@ -379,7 +394,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Handle routes that runs the request body for non-chunked") { tw => val routes = HttpRoutes @@ -405,7 +420,7 @@ class Http1ServerStageSpec extends Http4sSuite { } // Think of this as drunk HTTP pipelining - tickWheel.test("Http1ServerStage: routes should Not die when two requests come in back to back") { + fixture.test("Http1ServerStage: routes should Not die when two requests come in back to back") { tw => val routes = HttpRoutes .of[IO] { case req => @@ -429,7 +444,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Handle using the request body as the response body") { tw => val routes = HttpRoutes .of[IO] { case req => @@ -477,7 +492,7 @@ class Http1ServerStageSpec extends Http4sSuite { } .orNotFound - tickWheel.test("Http1ServerStage: routes should Handle trailing headers") { tw => + fixture.test("Http1ServerStage: routes should Handle trailing headers") { tw => (runRequest(tw, Seq(req("foo")), routes2).result).map { buff => val results = dropDate(ResponseParser.parseBuffer(buff)) assertEquals(results._1, Ok) @@ -485,7 +500,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test( + fixture.test( "Http1ServerStage: routes should Fail if you use the trailers before they have resolved") { tw => (runRequest(tw, Seq(req("bar")), routes2).result).map { buff => @@ -494,14 +509,14 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test("Http1ServerStage: routes should cancels on stage shutdown".flaky) { tw => + fixture.test("Http1ServerStage: routes should cancels on stage shutdown".flaky) { tw => Deferred[IO, Unit] .flatMap { canceled => Deferred[IO, Unit].flatMap { gate => val req = "POST /sync HTTP/1.1\r\nConnection:keep-alive\r\nContent-Length: 4\r\n\r\ndone" val app: HttpApp[IO] = HttpApp { _ => - gate.complete(()) >> IO.cancelable(_ => canceled.complete(())) + gate.complete(()) >> canceled.complete(()) >> IO.never[Response[IO]] } for { head <- IO(runRequest(tw, List(req), app)) @@ -513,14 +528,14 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test("Http1ServerStage: routes should Disconnect if we read an EOF") { tw => + fixture.test("Http1ServerStage: routes should Disconnect if we read an EOF") { tw => val head = runRequest(tw, Seq.empty, Kleisli.liftF(Ok(""))) head.result.map { _ => assert(head.closeCauses == Seq(None)) } } - tickWheel.test("Prevent response splitting attacks on status reason phrase") { tw => + fixture.test("Prevent response splitting attacks on status reason phrase") { tw => val rawReq = "GET /?reason=%0D%0AEvil:true%0D%0A HTTP/1.0\r\n\r\n" val head = runRequest( tw, @@ -534,7 +549,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test("Prevent response splitting attacks on field name") { tw => + fixture.test("Prevent response splitting attacks on field name") { tw => val rawReq = "GET /?fieldName=Fine:%0D%0AEvil:true%0D%0A HTTP/1.0\r\n\r\n" val head = runRequest( tw, @@ -548,7 +563,7 @@ class Http1ServerStageSpec extends Http4sSuite { } } - tickWheel.test("Prevent response splitting attacks on field value") { tw => + fixture.test("Prevent response splitting attacks on field value") { tw => val rawReq = "GET /?fieldValue=%0D%0AEvil:true%0D%0A HTTP/1.0\r\n\r\n" val head = runRequest( tw, @@ -562,5 +577,37 @@ class Http1ServerStageSpec extends Http4sSuite { val (_, headers, _) = ResponseParser.parseBuffer(buff) assertEquals(headers.find(_.name === ci"Evil"), None) } + + fixture.test("Http1ServerStage: don't deadlock TickWheelExecutor with uncancelable request") { + tw => + val reqUncancelable = List("GET /uncancelable HTTP/1.0\r\n\r\n") + val reqCancelable = List("GET /cancelable HTTP/1.0\r\n\r\n") + + (for { + uncancelableStarted <- Deferred[IO, Unit] + uncancelableCanceled <- Deferred[IO, Unit] + cancelableStarted <- Deferred[IO, Unit] + cancelableCanceled <- Deferred[IO, Unit] + app = HttpApp[IO] { + case req if req.pathInfo === path"/uncancelable" => + uncancelableStarted.complete(()) *> + IO.uncancelable { poll => + poll(uncancelableCanceled.complete(())) *> + cancelableCanceled.get + }.as(Response[IO]()) + case _ => + cancelableStarted.complete(()) *> IO.never.guarantee( + cancelableCanceled.complete(()).void) + } + head <- IO(runRequest(tw, reqUncancelable, app)) + _ <- uncancelableStarted.get + _ <- uncancelableCanceled.get + _ <- IO(head.sendInboundCommand(Disconnected)) + head2 <- IO(runRequest(tw, reqCancelable, app)) + _ <- cancelableStarted.get + _ <- IO(head2.sendInboundCommand(Disconnected)) + _ <- cancelableCanceled.get + } yield ()).assert + } } } diff --git a/blaze-server/src/test/scala/org/http4s/blaze/server/ServerTestRoutes.scala b/blaze-server/src/test/scala/org/http4s/blaze/server/ServerTestRoutes.scala index b3f643efda8..2c935737afc 100644 --- a/blaze-server/src/test/scala/org/http4s/blaze/server/ServerTestRoutes.scala +++ b/blaze-server/src/test/scala/org/http4s/blaze/server/ServerTestRoutes.scala @@ -112,14 +112,14 @@ object ServerTestRoutes extends Http4sDsl[IO] { (Status.NotModified, Set(connKeep), "")) ) - def apply()(implicit cs: ContextShift[IO]) = + def apply() = HttpRoutes .of[IO] { case req if req.method == Method.GET && req.pathInfo == path"/get" => Ok("get") case req if req.method == Method.GET && req.pathInfo == path"/chunked" => - Ok(eval(IO.shift *> IO("chu")) ++ eval(IO.shift *> IO("nk"))) + Ok(eval(IO.cede *> IO("chu")) ++ eval(IO.cede *> IO("nk"))) case req if req.method == Method.POST && req.pathInfo == path"/post" => Ok("post") diff --git a/boopickle/src/main/scala/org/http4s/booPickle/instances/BooPickleInstances.scala b/boopickle/src/main/scala/org/http4s/booPickle/instances/BooPickleInstances.scala index d15a1b1d4a6..e7a8119394e 100644 --- a/boopickle/src/main/scala/org/http4s/booPickle/instances/BooPickleInstances.scala +++ b/boopickle/src/main/scala/org/http4s/booPickle/instances/BooPickleInstances.scala @@ -20,7 +20,7 @@ package instances import boopickle.Default._ import boopickle.Pickler -import cats.effect.Sync +import cats.effect.Concurrent import fs2.Chunk import java.nio.ByteBuffer import org.http4s._ @@ -32,7 +32,7 @@ import scala.util.{Failure, Success} * Note that the media type is set for application/octet-stream */ trait BooPickleInstances { - private def booDecoderByteBuffer[F[_]: Sync, A](m: Media[F])(implicit + private def booDecoderByteBuffer[F[_]: Concurrent, A](m: Media[F])(implicit pickler: Pickler[A]): DecodeResult[F, A] = EntityDecoder.collectBinary(m).subflatMap { chunk => val bb = ByteBuffer.wrap(chunk.toArray) @@ -47,7 +47,7 @@ trait BooPickleInstances { /** Create an `EntityDecoder` for `A` given a `Pickler[A]` */ - def booOf[F[_]: Sync, A: Pickler]: EntityDecoder[F, A] = + def booOf[F[_]: Concurrent, A: Pickler]: EntityDecoder[F, A] = EntityDecoder.decodeBy(MediaType.application.`octet-stream`)(booDecoderByteBuffer[F, A]) /** Create an `EntityEncoder` for `A` given a `Pickler[A]` diff --git a/boopickle/src/test/scala/org/http4s/booPickle/BoopickleSuite.scala b/boopickle/src/test/scala/org/http4s/booPickle/BoopickleSuite.scala index d51e137b899..77236c9cba2 100644 --- a/boopickle/src/test/scala/org/http4s/booPickle/BoopickleSuite.scala +++ b/boopickle/src/test/scala/org/http4s/booPickle/BoopickleSuite.scala @@ -18,12 +18,12 @@ package org.http4s package booPickle import boopickle.Default._ -import cats.effect.IO import cats.Eq -import cats.effect.laws.util.TestContext +import cats.effect.IO +import cats.effect.testkit.TestContext +import org.http4s.MediaType import org.http4s.headers.`Content-Type` import org.http4s.laws.discipline.EntityCodecTests -import org.http4s.MediaType import org.scalacheck.Arbitrary import org.scalacheck.Gen import org.http4s.booPickle.implicits._ diff --git a/build.sbt b/build.sbt index 87b16784df0..c6bf3c2150d 100644 --- a/build.sbt +++ b/build.sbt @@ -8,7 +8,7 @@ import scala.xml.transform.{RewriteRule, RuleTransformer} // Global settings ThisBuild / crossScalaVersions := Seq(scala_213, scala_212, scala_3) ThisBuild / scalaVersion := (ThisBuild / crossScalaVersions).value.filter(_.startsWith("2.")).last -ThisBuild / baseVersion := "0.22" +ThisBuild / baseVersion := "0.23" ThisBuild / publishGithubUser := "rossabaker" ThisBuild / publishFullName := "Ross A. Baker" @@ -113,7 +113,7 @@ lazy val core = libraryProject("core") libraryDependencies ++= Seq( caseInsensitive, catsCore, - catsEffect, + catsEffectStd, catsParse.exclude("org.typelevel", "cats-core_2.13"), fs2Core, fs2Io, @@ -146,7 +146,8 @@ lazy val laws = libraryProject("laws") startYear := Some(2019), libraryDependencies ++= Seq( caseInsensitiveTesting, - catsEffectLaws, + catsEffect, + catsEffectTestkit, catsLaws, disciplineCore, ip4sTestKit, @@ -165,12 +166,12 @@ lazy val testing = libraryProject("testing") startYear := Some(2016), libraryDependencies ++= Seq( catsEffectLaws, - scalacheck, munitCatsEffect, munitDiscipline, + scalacheck, scalacheckEffect, scalacheckEffectMunit, - ), + ).map(_ % Test), ) .dependsOn(laws) @@ -218,9 +219,6 @@ lazy val client = libraryProject("client") .settings( description := "Base library for building http4s clients", startYear := Some(2014), - libraryDependencies ++= Seq( - jettyServlet % Test, - ) ) .dependsOn( core, @@ -286,6 +284,7 @@ lazy val emberServer = libraryProject("ember-server") mimaBinaryIssueFilters ++= Seq( ProblemFilters.exclude[DirectMissingMethodProblem]("org.http4s.ember.server.EmberServerBuilder#Defaults.maxConcurrency"), ProblemFilters.exclude[DirectMissingMethodProblem]("org.http4s.ember.server.internal.ServerHelpers.isKeepAlive"), + ProblemFilters.exclude[DirectMissingMethodProblem]("org.http4s.ember.server.EmberServerBuilder#Defaults.maxConcurrency"), ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.http4s.ember.server.internal.ServerHelpers.runApp") ), Test / parallelExecution := false @@ -320,7 +319,9 @@ lazy val blazeServer = libraryProject("blaze-server") .settings( description := "blaze implementation for http4s servers", startYear := Some(2014), - mimaBinaryIssueFilters ++= Seq( + mimaBinaryIssueFilters := Seq( + // private constructor + ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.http4s.blaze.server.BlazeServerBuilder.this") ) ) .dependsOn(blazeCore % "compile;test->test", server % "compile;test->test") @@ -350,8 +351,7 @@ lazy val asyncHttpClient = libraryProject("async-http-client") lazy val jettyClient = libraryProject("jetty-client") .settings( - description := "jetty implementation for http4s clients", - startYear := Some(2018), + description := "jetty implementation for http4s clients", startYear := Some(2018), libraryDependencies ++= Seq( Http4sPlugin.jettyClient, jettyHttp, @@ -445,7 +445,7 @@ lazy val circe = libraryProject("circe") libraryDependencies ++= Seq( circeCore, circeJawn, - circeTesting % Test, + circeTesting % Test ) ) .dependsOn(core, testing % "test->test", jawn % "compile;test->test") diff --git a/circe/src/main/scala/org/http4s/circe/CirceEntityDecoder.scala b/circe/src/main/scala/org/http4s/circe/CirceEntityDecoder.scala index 48b657f92a1..a862660cf75 100644 --- a/circe/src/main/scala/org/http4s/circe/CirceEntityDecoder.scala +++ b/circe/src/main/scala/org/http4s/circe/CirceEntityDecoder.scala @@ -16,7 +16,7 @@ package org.http4s.circe -import cats.effect.Sync +import cats.effect.Concurrent import io.circe.Decoder import org.http4s.EntityDecoder @@ -24,7 +24,7 @@ import org.http4s.EntityDecoder * without need to explicitly call `jsonOf`. */ trait CirceEntityDecoder { - implicit def circeEntityDecoder[F[_]: Sync, A: Decoder]: EntityDecoder[F, A] = jsonOf[F, A] + implicit def circeEntityDecoder[F[_]: Concurrent, A: Decoder]: EntityDecoder[F, A] = jsonOf[F, A] } object CirceEntityDecoder extends CirceEntityDecoder diff --git a/circe/src/main/scala/org/http4s/circe/CirceInstances.scala b/circe/src/main/scala/org/http4s/circe/CirceInstances.scala index 1b811c0b7e0..402c99b6c39 100644 --- a/circe/src/main/scala/org/http4s/circe/CirceInstances.scala +++ b/circe/src/main/scala/org/http4s/circe/CirceInstances.scala @@ -20,7 +20,7 @@ package circe import java.nio.ByteBuffer import cats.data.NonEmptyList -import cats.effect.Sync +import cats.effect.Concurrent import cats.syntax.either._ import fs2.{Chunk, Pull, Stream} import io.circe._ @@ -28,6 +28,7 @@ import io.circe.jawn._ import org.http4s.headers.`Content-Type` import org.http4s.jawn.JawnInstances import org.typelevel.jawn.ParseException +import org.typelevel.jawn.fs2.unwrapJsonArray trait CirceInstances extends JawnInstances { protected val circeSupportParser = @@ -43,13 +44,13 @@ trait CirceInstances extends JawnInstances { protected def jsonDecodeError: (Json, NonEmptyList[DecodingFailure]) => DecodeFailure = CirceInstances.defaultJsonDecodeError - def jsonDecoderIncremental[F[_]: Sync]: EntityDecoder[F, Json] = + def jsonDecoderIncremental[F[_]: Concurrent]: EntityDecoder[F, Json] = this.jawnDecoder[F, Json] - def jsonDecoderByteBuffer[F[_]: Sync]: EntityDecoder[F, Json] = + def jsonDecoderByteBuffer[F[_]: Concurrent]: EntityDecoder[F, Json] = EntityDecoder.decodeBy(MediaType.application.json)(jsonDecoderByteBufferImpl[F]) - private def jsonDecoderByteBufferImpl[F[_]: Sync](m: Media[F]): DecodeResult[F, Json] = + private def jsonDecoderByteBufferImpl[F[_]: Concurrent](m: Media[F]): DecodeResult[F, Json] = EntityDecoder.collectBinary(m).subflatMap { chunk => val bb = ByteBuffer.wrap(chunk.toArray) if (bb.hasRemaining) @@ -62,10 +63,10 @@ trait CirceInstances extends JawnInstances { } // default cutoff value is based on benchmarks results - implicit def jsonDecoder[F[_]: Sync]: EntityDecoder[F, Json] = + implicit def jsonDecoder[F[_]: Concurrent]: EntityDecoder[F, Json] = jsonDecoderAdaptive(cutoff = 100000, MediaType.application.json) - def jsonDecoderAdaptive[F[_]: Sync]( + def jsonDecoderAdaptive[F[_]: Concurrent]( cutoff: Long, r1: MediaRange, rs: MediaRange*): EntityDecoder[F, Json] = @@ -77,20 +78,20 @@ trait CirceInstances extends JawnInstances { } } - def jsonOf[F[_]: Sync, A: Decoder]: EntityDecoder[F, A] = + def jsonOf[F[_]: Concurrent, A: Decoder]: EntityDecoder[F, A] = jsonOfWithMedia(MediaType.application.json) - def jsonOfSensitive[F[_]: Sync, A: Decoder](redact: Json => String): EntityDecoder[F, A] = + def jsonOfSensitive[F[_]: Concurrent, A: Decoder](redact: Json => String): EntityDecoder[F, A] = jsonOfWithSensitiveMedia(redact, MediaType.application.json) def jsonOfWithMedia[F[_], A](r1: MediaRange, rs: MediaRange*)(implicit - F: Sync[F], + F: Concurrent[F], decoder: Decoder[A]): EntityDecoder[F, A] = jsonOfWithMediaHelper[F, A](r1, jsonDecodeError, rs: _*) def jsonOfWithSensitiveMedia[F[_], A](redact: Json => String, r1: MediaRange, rs: MediaRange*)( implicit - F: Sync[F], + F: Concurrent[F], decoder: Decoder[A]): EntityDecoder[F, A] = jsonOfWithMediaHelper[F, A]( r1, @@ -102,7 +103,7 @@ trait CirceInstances extends JawnInstances { private def jsonOfWithMediaHelper[F[_], A]( r1: MediaRange, decodeErrorHandler: (Json, NonEmptyList[DecodingFailure]) => DecodeFailure, - rs: MediaRange*)(implicit F: Sync[F], decoder: Decoder[A]): EntityDecoder[F, A] = + rs: MediaRange*)(implicit F: Concurrent[F], decoder: Decoder[A]): EntityDecoder[F, A] = jsonDecoderAdaptive[F](cutoff = 100000, r1, rs: _*).flatMapR { json => decoder .decodeJson(json) @@ -117,7 +118,9 @@ trait CirceInstances extends JawnInstances { * In case of a failure, returns an [[InvalidMessageBodyFailure]] with the cause containing * a [[DecodingFailures]] exception, from which the errors can be extracted. */ - def accumulatingJsonOf[F[_], A](implicit F: Sync[F], decoder: Decoder[A]): EntityDecoder[F, A] = + def accumulatingJsonOf[F[_], A](implicit + F: Concurrent[F], + decoder: Decoder[A]): EntityDecoder[F, A] = jsonDecoder[F].flatMapR { json => decoder .decodeAccumulating(json.hcursor) @@ -145,9 +148,9 @@ trait CirceInstances extends JawnInstances { implicit def streamJsonArrayEncoder[F[_]]: EntityEncoder[F, Stream[F, Json]] = streamJsonArrayEncoderWithPrinter(defaultPrinter) - implicit def streamJsonArrayDecoder[F[_]: Sync]: EntityDecoder[F, Stream[F, Json]] = + implicit def streamJsonArrayDecoder[F[_]: Concurrent]: EntityDecoder[F, Stream[F, Json]] = EntityDecoder.decodeBy(MediaType.application.json) { media => - DecodeResult.successT(media.body.chunks.through(jawnfs2.unwrapJsonArray)) + DecodeResult.successT(media.body.chunks.through(unwrapJsonArray)) } /** An [[EntityEncoder]] for a [[fs2.Stream]] of JSONs, which will encode it as a single JSON array. */ @@ -280,7 +283,7 @@ object CirceInstances { case None => Pull.output(emptyArray) case Some((hd, tl)) => Pull.output( - Chunk.concatBytes(Vector(CirceInstances.openBrace, fromJsonToChunk(printer)(hd))) + Chunk.concat(Vector(CirceInstances.openBrace, fromJsonToChunk(printer)(hd))) ) >> // Output First Json As Chunk with leading `[` tl.repeatPull { _.uncons.flatMap { @@ -293,7 +296,7 @@ object CirceInstances { bldr += CirceInstances.comma bldr += fromJsonToChunk(printer)(o) } - Chunk.concatBytes(bldr.result()) + Chunk.concat(bldr.result()) } Pull.output(interspersed) >> Pull.pure(Some(tl)) } @@ -309,7 +312,7 @@ object CirceInstances { Chunk.singleton(']'.toByte) private final val emptyArray: Chunk[Byte] = - Chunk.bytes(Array('['.toByte, ']'.toByte)) + Chunk.array(Array('['.toByte, ']'.toByte)) private final val comma: Chunk[Byte] = Chunk.singleton(','.toByte) @@ -323,10 +326,10 @@ object CirceInstances { def asJsonDecode[A](implicit F: JsonDecoder[F], decoder: Decoder[A]): F[A] = F.asJsonDecode(req) - def decodeJson[A](implicit F: Sync[F], decoder: Decoder[A]): F[A] = + def decodeJson[A](implicit F: Concurrent[F], decoder: Decoder[A]): F[A] = req.as(F, jsonOf[F, A]) - def json(implicit F: Sync[F]): F[Json] = + def json(implicit F: Concurrent[F]): F[Json] = req.as(F, jsonDecoder[F]) } } diff --git a/circe/src/main/scala/org/http4s/circe/CirceSensitiveDataEntityDecoder.scala b/circe/src/main/scala/org/http4s/circe/CirceSensitiveDataEntityDecoder.scala index 008526a7966..587393aeeec 100644 --- a/circe/src/main/scala/org/http4s/circe/CirceSensitiveDataEntityDecoder.scala +++ b/circe/src/main/scala/org/http4s/circe/CirceSensitiveDataEntityDecoder.scala @@ -16,7 +16,7 @@ package org.http4s.circe -import cats.effect.Sync +import cats.effect.Concurrent import io.circe.Decoder import org.http4s.EntityDecoder @@ -28,7 +28,7 @@ import org.http4s.EntityDecoder * that includes the sensitive JSON. */ trait CirceSensitiveDataEntityDecoder { - implicit def circeEntityDecoder[F[_]: Sync, A: Decoder]: EntityDecoder[F, A] = + implicit def circeEntityDecoder[F[_]: Concurrent, A: Decoder]: EntityDecoder[F, A] = jsonOfSensitive[F, A](_ => "") } diff --git a/circe/src/main/scala/org/http4s/circe/JsonDecoder.scala b/circe/src/main/scala/org/http4s/circe/JsonDecoder.scala index 61d7a30db7f..b4be61d78d6 100644 --- a/circe/src/main/scala/org/http4s/circe/JsonDecoder.scala +++ b/circe/src/main/scala/org/http4s/circe/JsonDecoder.scala @@ -16,7 +16,7 @@ package org.http4s.circe -import cats.effect.Sync +import cats.effect.Concurrent import org.http4s._ import io.circe._ @@ -33,7 +33,7 @@ trait JsonDecoder[F[_]] { object JsonDecoder { def apply[F[_]](implicit ev: JsonDecoder[F]): JsonDecoder[F] = ev - implicit def impl[F[_]: Sync]: JsonDecoder[F] = + implicit def impl[F[_]: Concurrent]: JsonDecoder[F] = new JsonDecoder[F] { def asJson(m: Message[F]): F[Json] = m.as[Json] def asJsonDecode[A: Decoder](m: Message[F]): F[A] = m.decodeJson diff --git a/circe/src/main/scala/org/http4s/circe/middleware/JsonDebugErrorHandler.scala b/circe/src/main/scala/org/http4s/circe/middleware/JsonDebugErrorHandler.scala index 781067bdf02..574aa3e4dd8 100644 --- a/circe/src/main/scala/org/http4s/circe/middleware/JsonDebugErrorHandler.scala +++ b/circe/src/main/scala/org/http4s/circe/middleware/JsonDebugErrorHandler.scala @@ -32,7 +32,7 @@ object JsonDebugErrorHandler { org.log4s.getLogger("org.http4s.circe.middleware.jsondebugerrorhandler.service-errors") // Can be parametric on my other PR is merged. - def apply[F[_]: Sync, G[_]]( + def apply[F[_]: Concurrent, G[_]]( service: Kleisli[F, Request[G], Response[G]], redactWhen: CIString => Boolean = Headers.SensitiveHeaders.contains ): Kleisli[F, Request[G], Response[G]] = diff --git a/circe/src/test/scala/org/http4s/circe/CirceSuite.scala b/circe/src/test/scala/org/http4s/circe/CirceSuite.scala index 952942c97f6..c79e001ada0 100644 --- a/circe/src/test/scala/org/http4s/circe/CirceSuite.scala +++ b/circe/src/test/scala/org/http4s/circe/CirceSuite.scala @@ -19,7 +19,7 @@ package circe.test // Get out of circe package so we can import custom instances import cats.data.NonEmptyList import cats.effect.IO -import cats.effect.laws.util.TestContext +import cats.effect.testkit.TestContext import cats.syntax.all._ import fs2.Stream import io.circe._ @@ -203,7 +203,8 @@ class CirceSuite extends JawnDecodeSupportSuite[Json] with Http4sLawSuite { stream <- streamJsonArrayDecoder[IO].decode( Media( Stream.fromIterator[IO]( - """[{"test1":"CirceSupport"},{"test2":"CirceSupport"}]""".getBytes.iterator), + """[{"test1":"CirceSupport"},{"test2":"CirceSupport"}]""".getBytes.iterator, + 128), Headers("content-type" -> "application/json") ), true @@ -223,7 +224,8 @@ class CirceSuite extends JawnDecodeSupportSuite[Json] with Http4sLawSuite { val result = streamJsonArrayDecoder[IO].decode( Media( Stream.fromIterator[IO]( - """[{"test1":"CirceSupport"},{"test2":"CirceSupport"}]""".getBytes.iterator), + """[{"test1":"CirceSupport"},{"test2":"CirceSupport"}]""".getBytes.iterator, + 128), Headers.empty ), true @@ -235,7 +237,8 @@ class CirceSuite extends JawnDecodeSupportSuite[Json] with Http4sLawSuite { val result = streamJsonArrayDecoder[IO].decode( Media( Stream.fromIterator[IO]( - """[{"test1":"CirceSupport"},{"test2":CirceSupport"}]""".getBytes.iterator), + """[{"test1":"CirceSupport"},{"test2":CirceSupport"}]""".getBytes.iterator, + 128), Headers("content-type" -> "application/json") ), true @@ -248,7 +251,8 @@ class CirceSuite extends JawnDecodeSupportSuite[Json] with Http4sLawSuite { stream <- streamJsonArrayDecoder[IO].decode( Media( Stream.fromIterator[IO]( - """[{"test1":"CirceSupport"},{"test2":CirceSupport"}]""".getBytes.iterator), + """[{"test1":"CirceSupport"},{"test2":CirceSupport"}]""".getBytes.iterator, + 128), Headers("content-type" -> "application/json") ), true @@ -257,7 +261,7 @@ class CirceSuite extends JawnDecodeSupportSuite[Json] with Http4sLawSuite { stream.map(Printer.noSpaces.print).compile.toList.map(_.asRight[DecodeFailure])) } yield list).value.attempt .assertEquals(Left( - ParseException("expected json value got 'C...' (line 1, column 36)", 35, 1, 36))) + ParseException("expected json value got 'CirceS...' (line 1, column 36)", 35, 1, 36))) } test("json handle the optionality of asNumber") { diff --git a/client/src/main/scala/org/http4s/client/Client.scala b/client/src/main/scala/org/http4s/client/Client.scala index b50c4b82a8b..73d57db3239 100644 --- a/client/src/main/scala/org/http4s/client/Client.scala +++ b/client/src/main/scala/org/http4s/client/Client.scala @@ -20,7 +20,7 @@ package client import cats.~> import cats.data.Kleisli import cats.effect._ -import cats.effect.concurrent.Ref +import cats.effect.Ref import cats.syntax.all._ import fs2._ import java.io.IOException @@ -77,12 +77,6 @@ trait Client[F[_]] { */ def stream(req: Request[F]): Stream[F, Response[F]] - @deprecated("Use `client.stream(req).flatMap(f)`", "0.19.0-M4") - def streaming[A](req: Request[F])(f: Response[F] => Stream[F, A]): Stream[F, A] - - @deprecated("Use `Stream.eval(req).flatMap(client.stream).flatMap(f)`", "0.19.0-M4") - def streaming[A](req: F[Request[F]])(f: Response[F] => Stream[F, A]): Stream[F, A] - def expectOr[A](req: Request[F])(onError: Response[F] => F[Throwable])(implicit d: EntityDecoder[F, A]): F[A] @@ -171,7 +165,8 @@ trait Client[F[_]] { /** Translates the effect type of this client from F to G */ - def translate[G[_]: Sync](fk: F ~> G)(gK: G ~> F): Client[G] = + def translate[G[_]: Async](fk: F ~> G)(gK: G ~> F)(implicit + F: MonadCancel[F, Throwable]): Client[G] = Client((req: Request[G]) => run( req.mapK(gK) @@ -181,26 +176,17 @@ trait Client[F[_]] { object Client { def apply[F[_]](f: Request[F] => Resource[F, Response[F]])(implicit - F: BracketThrow[F]): Client[F] = + F: MonadCancelThrow[F]): Client[F] = new DefaultClient[F] { def run(req: Request[F]): Resource[F, Response[F]] = f(req) } - /** Creates a client from the specified service. Useful for generating - * pre-determined responses for requests in testing. - * - * @param service the service to respond to requests to this client - */ - @deprecated("Use fromHttpApp instead. Call service.orNotFound to turn into an HttpApp.", "0.19") - def fromHttpService[F[_]](service: HttpRoutes[F])(implicit F: Sync[F]): Client[F] = - fromHttpApp(service.orNotFound) - /** Creates a client from the specified [[HttpApp]]. Useful for * generating pre-determined responses for requests in testing. * * @param app the [[HttpApp]] to respond to requests to this client */ - def fromHttpApp[F[_]](app: HttpApp[F])(implicit F: Sync[F]): Client[F] = + def fromHttpApp[F[_]](app: HttpApp[F])(implicit F: Async[F]): Client[F] = Client { (req: Request[F]) => Resource.suspend { Ref[F].of(false).map { disposed => @@ -229,7 +215,7 @@ object Client { /** This method introduces an important way for the effectful backends to allow tracing. As Kleisli types * form the backend of tracing and these transformations are non-trivial. */ - def liftKleisli[F[_]: BracketThrow: cats.Defer, A](client: Client[F]): Client[Kleisli[F, A, *]] = + def liftKleisli[F[_]: MonadCancelThrow, A](client: Client[F]): Client[Kleisli[F, A, *]] = Client { (req: Request[Kleisli[F, A, *]]) => Resource.eval(Kleisli.ask[F, A]).flatMap { a => client diff --git a/client/src/main/scala/org/http4s/client/DefaultClient.scala b/client/src/main/scala/org/http4s/client/DefaultClient.scala index 63292981c88..b16ad74a08c 100644 --- a/client/src/main/scala/org/http4s/client/DefaultClient.scala +++ b/client/src/main/scala/org/http4s/client/DefaultClient.scala @@ -19,13 +19,14 @@ package client import cats.Applicative import cats.data.Kleisli -import cats.effect.{BracketThrow, Resource} +import cats.effect.{MonadCancelThrow, Resource} import cats.syntax.all._ import fs2.Stream import org.http4s.Status.Successful import org.http4s.headers.{Accept, MediaRangeAndQValue} -private[http4s] abstract class DefaultClient[F[_]](implicit F: BracketThrow[F]) extends Client[F] { +private[http4s] abstract class DefaultClient[F[_]](implicit F: MonadCancelThrow[F]) + extends Client[F] { def run(req: Request[F]): Resource[F, Response[F]] /** Submits a request, and provides a callback to process the response. diff --git a/client/src/main/scala/org/http4s/client/JavaNetClientBuilder.scala b/client/src/main/scala/org/http4s/client/JavaNetClientBuilder.scala index bb6adeff1b9..20664fae4be 100644 --- a/client/src/main/scala/org/http4s/client/JavaNetClientBuilder.scala +++ b/client/src/main/scala/org/http4s/client/JavaNetClientBuilder.scala @@ -17,7 +17,7 @@ package org.http4s package client -import cats.effect.{Async, Blocker, ContextShift, Resource, Sync} +import cats.effect.{Async, Resource, Sync} import cats.syntax.all._ import fs2.Stream import fs2.io.{readInputStream, writeOutputStream} @@ -28,7 +28,6 @@ import org.http4s.internal.BackendBuilder import org.http4s.internal.CollectionCompat.CollectionConverters._ import org.typelevel.ci.CIString import scala.concurrent.duration.{Duration, FiniteDuration} -import scala.concurrent.{ExecutionContext, blocking} /** Builder for a [[Client]] backed by on `java.net.HttpUrlConnection`. * @@ -38,8 +37,6 @@ import scala.concurrent.{ExecutionContext, blocking} * * All I/O operations in this client are blocking. * - * @param blockingExecutionContext An `ExecutionContext` on which - * blocking operations will be performed. * @define WHYNOSHUTDOWN Creation of the client allocates no * resources, and any resources allocated while using this client * are reclaimed by the JVM at its own leisure. @@ -49,25 +46,22 @@ sealed abstract class JavaNetClientBuilder[F[_]] private ( val readTimeout: Duration, val proxy: Option[Proxy], val hostnameVerifier: Option[HostnameVerifier], - val sslSocketFactory: Option[SSLSocketFactory], - val blocker: Blocker -)(implicit protected val F: Async[F], cs: ContextShift[F]) + val sslSocketFactory: Option[SSLSocketFactory] +)(implicit protected val F: Async[F]) extends BackendBuilder[F, Client[F]] { private def copy( connectTimeout: Duration = connectTimeout, readTimeout: Duration = readTimeout, proxy: Option[Proxy] = proxy, hostnameVerifier: Option[HostnameVerifier] = hostnameVerifier, - sslSocketFactory: Option[SSLSocketFactory] = sslSocketFactory, - blocker: Blocker = blocker + sslSocketFactory: Option[SSLSocketFactory] = sslSocketFactory ): JavaNetClientBuilder[F] = new JavaNetClientBuilder[F]( connectTimeout = connectTimeout, readTimeout = readTimeout, proxy = proxy, hostnameVerifier = hostnameVerifier, - sslSocketFactory = sslSocketFactory, - blocker = blocker + sslSocketFactory = sslSocketFactory ) {} def withConnectTimeout(connectTimeout: Duration): JavaNetClientBuilder[F] = @@ -99,14 +93,6 @@ sealed abstract class JavaNetClientBuilder[F[_]] private ( def withoutSslSocketFactory: JavaNetClientBuilder[F] = withSslSocketFactoryOption(None) - def withBlocker(blocker: Blocker): JavaNetClientBuilder[F] = - copy(blocker = blocker) - - @deprecated("Use withBlocker instead", "0.21.0") - def withBlockingExecutionContext( - blockingExecutionContext: ExecutionContext): JavaNetClientBuilder[F] = - copy(blocker = Blocker.liftExecutionContext(blockingExecutionContext)) - /** Creates a [[Client]]. * * The shutdown of this client is a no-op. $WHYNOSHUTDOWN @@ -124,7 +110,8 @@ sealed abstract class JavaNetClientBuilder[F[_]] private ( }) _ <- F.delay(conn.setInstanceFollowRedirects(false)) _ <- F.delay(conn.setDoInput(true)) - resp <- blocker.blockOn(blocking(fetchResponse(req, conn))) + // TODO: fix the blocking here + resp <- fetchResponse(req, conn) } yield resp for { @@ -144,9 +131,9 @@ sealed abstract class JavaNetClientBuilder[F[_]] private ( private def fetchResponse(req: Request[F], conn: HttpURLConnection) = for { _ <- writeBody(req, conn) - code <- F.delay(conn.getResponseCode) + code <- F.blocking(conn.getResponseCode) status <- F.fromEither(Status.fromInt(code)) - headers <- F.delay( + headers <- F.blocking( Headers( conn.getHeaderFields.asScala .filter(_._1 != null) @@ -174,7 +161,7 @@ sealed abstract class JavaNetClientBuilder[F[_]] private ( F.delay(conn.setDoOutput(true)) *> F.delay(conn.setChunkedStreamingMode(4096)) *> req.body - .through(writeOutputStream(F.delay(conn.getOutputStream), blocker, false)) + .through(writeOutputStream(F.delay(conn.getOutputStream), false)) .compile .drain else @@ -183,7 +170,7 @@ sealed abstract class JavaNetClientBuilder[F[_]] private ( F.delay(conn.setDoOutput(true)) *> F.delay(conn.setFixedLengthStreamingMode(len)) *> req.body - .through(writeOutputStream(F.delay(conn.getOutputStream), blocker, false)) + .through(writeOutputStream(F.delay(conn.getOutputStream), false)) .compile .drain case _ => @@ -197,7 +184,7 @@ sealed abstract class JavaNetClientBuilder[F[_]] private ( F.delay(Option(conn.getErrorStream)) } Stream.eval(inputStream).flatMap { - case Some(in) => readInputStream(F.pure(in), 4096, blocker, false) + case Some(in) => readInputStream(F.pure(in), 4096, false) case None => Stream.empty } } @@ -219,13 +206,12 @@ object JavaNetClientBuilder { /** @param blockingExecutionContext An `ExecutionContext` on which * blocking operations will be performed. */ - def apply[F[_]: Async: ContextShift](blocker: Blocker): JavaNetClientBuilder[F] = + def apply[F[_]: Async]: JavaNetClientBuilder[F] = new JavaNetClientBuilder[F]( connectTimeout = defaults.ConnectTimeout, readTimeout = defaults.RequestTimeout, proxy = None, hostnameVerifier = None, - sslSocketFactory = None, - blocker = blocker + sslSocketFactory = None ) {} } diff --git a/client/src/main/scala/org/http4s/client/middleware/CookieJar.scala b/client/src/main/scala/org/http4s/client/middleware/CookieJar.scala index 4f48055954d..f3f3138fc22 100644 --- a/client/src/main/scala/org/http4s/client/middleware/CookieJar.scala +++ b/client/src/main/scala/org/http4s/client/middleware/CookieJar.scala @@ -18,8 +18,7 @@ package org.http4s.client.middleware import cats._ import cats.syntax.all._ -import cats.effect._ -import cats.effect.concurrent._ +import cats.effect.kernel._ import org.http4s._ import org.http4s.client.Client @@ -58,7 +57,7 @@ object CookieJar { /** Middleware Constructor Using a Provided [[CookieJar]]. */ - def apply[F[_]: Sync]( + def apply[F[_]: Async]( alg: CookieJar[F] )( client: Client[F] @@ -79,29 +78,29 @@ object CookieJar { /** Constructor which builds a non-exposed CookieJar * and applies it to the client. */ - def impl[F[_]: Sync: Timer](c: Client[F]): F[Client[F]] = + def impl[F[_]: Async](c: Client[F]): F[Client[F]] = in[F, F](c) /** Like [[impl]] except it allows the creation of the middleware in a * different HKT than the client is in. */ - def in[F[_]: Sync: Timer, G[_]: Sync](c: Client[F]): G[Client[F]] = + def in[F[_]: Async, G[_]: Sync](c: Client[F]): G[Client[F]] = jarIn[F, G].map(apply(_)(c)) /** Jar Constructor */ - def jarImpl[F[_]: Sync: Clock]: F[CookieJar[F]] = + def jarImpl[F[_]: Async]: F[CookieJar[F]] = jarIn[F, F] /** Like [[jarImpl]] except it allows the creation of the CookieJar in a * different HKT than the client is in. */ - def jarIn[F[_]: Sync: Clock, G[_]: Sync]: G[CookieJar[F]] = + def jarIn[F[_]: Async, G[_]: Sync]: G[CookieJar[F]] = Ref.in[G, F, Map[CookieKey, CookieValue]](Map.empty).map { ref => new CookieJarRefImpl[F](ref) {} } - private[CookieJar] class CookieJarRefImpl[F[_]: Sync: Clock]( + private[CookieJar] class CookieJarRefImpl[F[_]: Async]( ref: Ref[F, Map[CookieKey, CookieValue]] ) extends CookieJar[F] { override def evictExpired: F[Unit] = diff --git a/client/src/main/scala/org/http4s/client/middleware/DestinationAttribute.scala b/client/src/main/scala/org/http4s/client/middleware/DestinationAttribute.scala index 681edddcddb..dbbaca4f678 100644 --- a/client/src/main/scala/org/http4s/client/middleware/DestinationAttribute.scala +++ b/client/src/main/scala/org/http4s/client/middleware/DestinationAttribute.scala @@ -24,7 +24,7 @@ import org.typelevel.vault._ /** Client middleware that sets the destination attribute of every request to the specified value. */ object DestinationAttribute { - def apply[F[_]: Sync](client: Client[F], destination: String): Client[F] = + def apply[F[_]: Async](client: Client[F], destination: String): Client[F] = Client { req => client.run(req.withAttribute(Destination, destination)) } @@ -36,7 +36,7 @@ object DestinationAttribute { */ def getDestination[F[_]](): Request[F] => Option[String] = _.attributes.lookup(Destination) - val Destination = Key.newKey[IO, String].unsafeRunSync() + val Destination = Key.newKey[SyncIO, String].unsafeRunSync() val EmptyDestination = "" } diff --git a/client/src/main/scala/org/http4s/client/middleware/FollowRedirect.scala b/client/src/main/scala/org/http4s/client/middleware/FollowRedirect.scala index 6ac4ff29f3a..df334bd0dd7 100644 --- a/client/src/main/scala/org/http4s/client/middleware/FollowRedirect.scala +++ b/client/src/main/scala/org/http4s/client/middleware/FollowRedirect.scala @@ -19,6 +19,7 @@ package client package middleware import cats.effect._ +import cats.effect.std.Hotswap import cats.syntax.all._ import org.http4s.Method._ import org.http4s.headers._ @@ -94,27 +95,29 @@ object FollowRedirect { ) } - def prepareLoop(req: Request[F], redirects: Int): F[Resource[F, Response[F]]] = - F.continual(client.run(req).allocated) { - case Right((resp, dispose)) => - (methodForRedirect(req, resp), resp.headers.get[Location]) match { - case (Some(method), Some(loc)) if redirects < maxRedirects => - val nextReq = nextRequest(req, loc.uri, method, resp.cookies) - dispose >> prepareLoop(nextReq, redirects + 1).map(_.map { response => - // prepend because `prepareLoop` is recursive - response.withAttribute(redirectUrisKey, nextReq.uri +: getRedirectUris(response)) - }) - case _ => - // IF the response is missing the Location header, OR there is no method to redirect, - // OR we have exceeded max number of redirections, THEN we redirect no more - Resource.make(resp.pure[F])(_ => dispose).pure[F] - } - - case Left(e) => - F.raiseError(e) + def redirectLoop( + req: Request[F], + redirects: Int, + hotswap: Hotswap[F, Response[F]]): F[Response[F]] = + hotswap.swap(client.run(req)).flatMap { resp => + val l: Option[Location] = resp.headers.get[Location] + (methodForRedirect(req, resp), l) match { + case (Some(method), Some(loc)) if redirects < maxRedirects => + val nextReq = nextRequest(req, loc.uri, method, resp.cookies) + redirectLoop(nextReq, redirects + 1, hotswap) + .map(res => res.withAttribute(redirectUrisKey, nextReq.uri +: getRedirectUris(res))) + case _ => + // IF the response is missing the Location header, OR there is no method to redirect, + // OR we have exceeded max number of redirections, THEN we redirect no more + resp.pure[F] + } } - Client(req => Resource.suspend(prepareLoop(req, 0))) + Client { req => + Hotswap.create[F, Response[F]].flatMap { case hotswap => + Resource.eval(redirectLoop(req, 0, hotswap)) + } + } } private def methodForRedirect[F[_]](req: Request[F], resp: Response[F]): Option[Method] = @@ -161,7 +164,7 @@ object FollowRedirect { None } - private val redirectUrisKey = Key.newKey[IO, List[Uri]].unsafeRunSync() + private val redirectUrisKey = Key.newKey[SyncIO, List[Uri]].unsafeRunSync() /** Get the redirection URIs for a `response`. * Excludes the initial request URI diff --git a/client/src/main/scala/org/http4s/client/middleware/GZip.scala b/client/src/main/scala/org/http4s/client/middleware/GZip.scala index aca5a301257..5966fc0ae36 100644 --- a/client/src/main/scala/org/http4s/client/middleware/GZip.scala +++ b/client/src/main/scala/org/http4s/client/middleware/GZip.scala @@ -19,8 +19,9 @@ package client package middleware import cats.data.NonEmptyList -import cats.effect.{BracketThrow, Sync} +import cats.effect.Async import fs2.{Pipe, Pull, Stream} +import fs2.compression.{Compression, DeflateParams} import org.http4s.headers.{`Accept-Encoding`, `Content-Encoding`} import org.typelevel.ci._ import scala.util.control.NoStackTrace @@ -31,7 +32,7 @@ object GZip { private val supportedCompressions = NonEmptyList.of(ContentCoding.gzip, ContentCoding.deflate) - def apply[F[_]](bufferSize: Int = 32 * 1024)(client: Client[F])(implicit F: Sync[F]): Client[F] = + def apply[F[_]](bufferSize: Int = 32 * 1024)(client: Client[F])(implicit F: Async[F]): Client[F] = Client[F] { req => val reqWithEncoding = addHeaders(req) val responseResource = client.run(reqWithEncoding) @@ -50,18 +51,18 @@ object GZip { } private def decompress[F[_]](bufferSize: Int, response: Response[F])(implicit - F: Sync[F]): Response[F] = + F: Async[F]): Response[F] = response.headers.get[`Content-Encoding`] match { case Some(header) if header.contentCoding == ContentCoding.gzip || header.contentCoding == ContentCoding.`x-gzip` => val gunzip: Pipe[F, Byte, Byte] = - _.through(fs2.compression.gunzip(bufferSize)).flatMap(_.content) + _.through(Compression[F].gunzip(bufferSize)).flatMap(_.content) response .filterHeaders(nonCompressionHeader) .withBodyStream(response.body.through(decompressWith(gunzip))) case Some(header) if header.contentCoding == ContentCoding.deflate => - val deflate: Pipe[F, Byte, Byte] = fs2.compression.deflate(bufferSize) + val deflate: Pipe[F, Byte, Byte] = Compression[F].deflate(DeflateParams(bufferSize)) response .filterHeaders(nonCompressionHeader) .withBodyStream(response.body.through(decompressWith(deflate))) @@ -71,7 +72,7 @@ object GZip { } private def decompressWith[F[_]](decompressor: Pipe[F, Byte, Byte])(implicit - F: BracketThrow[F]): Pipe[F, Byte, Byte] = + F: Async[F]): Pipe[F, Byte, Byte] = _.pull.peek1 .flatMap { case None => Pull.raiseError(EmptyBodyException) diff --git a/client/src/main/scala/org/http4s/client/middleware/Logger.scala b/client/src/main/scala/org/http4s/client/middleware/Logger.scala index c189be07b9d..a128f007d91 100644 --- a/client/src/main/scala/org/http4s/client/middleware/Logger.scala +++ b/client/src/main/scala/org/http4s/client/middleware/Logger.scala @@ -25,7 +25,7 @@ import org.typelevel.ci.CIString /** Simple Middleware for Logging All Requests and Responses */ object Logger { - def apply[F[_]: Concurrent]( + def apply[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -37,7 +37,7 @@ object Logger { ) ) - def logBodyText[F[_]: Concurrent]( + def logBodyText[F[_]: Async]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -53,11 +53,11 @@ object Logger { logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains)( - log: String => F[Unit])(implicit F: Sync[F]): F[Unit] = + log: String => F[Unit])(implicit F: Async[F]): F[Unit] = org.http4s.internal.Logger .logMessage[F, A](message)(logHeaders, logBody, redactHeadersWhen)(log) - def colored[F[_]: Concurrent]( + def colored[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, diff --git a/client/src/main/scala/org/http4s/client/middleware/Metrics.scala b/client/src/main/scala/org/http4s/client/middleware/Metrics.scala index 34a877adda7..e0beb043c72 100644 --- a/client/src/main/scala/org/http4s/client/middleware/Metrics.scala +++ b/client/src/main/scala/org/http4s/client/middleware/Metrics.scala @@ -16,11 +16,9 @@ package org.http4s.client.middleware -import cats.effect.{Clock, Resource, Sync} +import cats.effect.Concurrent +import cats.effect.{Clock, Ref, Resource} import cats.syntax.all._ -import java.util.concurrent.TimeUnit - -import cats.effect.concurrent.Ref import org.http4s.{Request, Response, Status} import org.http4s.client.Client import org.http4s.metrics.MetricsOps @@ -52,62 +50,78 @@ object Metrics { ops: MetricsOps[F], classifierF: Request[F] => Option[String] = { (_: Request[F]) => None - })(client: Client[F])(implicit F: Sync[F], clock: Clock[F]): Client[F] = + })(client: Client[F])(implicit F: Clock[F], C: Concurrent[F]): Client[F] = + effect(ops, classifierF.andThen(_.pure[F]))(client) + + /** Wraps a [[Client]] with a middleware capable of recording metrics + * + * Same as [[apply]], but can classify requests effectually, e.g. performing side-effects or examining the body. + * Failed attempt to classify the request (e.g. failing with `F.raiseError`) leads to not recording metrics for that request. + * + * @note Compiling the request body in `classifierF` is unsafe, unless you are using some caching middleware. + * + * @param ops a algebra describing the metrics operations + * @param classifierF a function that allows to add a classifier that can be customized per request + * @param client the [[Client]] to gather metrics from + * @return the metrics middleware wrapping the [[Client]] + */ + def effect[F[_]](ops: MetricsOps[F], classifierF: Request[F] => F[Option[String]])( + client: Client[F])(implicit F: Clock[F], C: Concurrent[F]): Client[F] = Client(withMetrics(client, ops, classifierF)) private def withMetrics[F[_]]( client: Client[F], ops: MetricsOps[F], - classifierF: Request[F] => Option[String])( - req: Request[F])(implicit F: Sync[F], clock: Clock[F]): Resource[F, Response[F]] = + classifierF: Request[F] => F[Option[String]])( + req: Request[F])(implicit F: Clock[F], C: Concurrent[F]): Resource[F, Response[F]] = for { - statusRef <- Resource.eval(Ref.of[F, Option[Status]](None)) - start <- Resource.eval(clock.monotonic(TimeUnit.NANOSECONDS)) + statusRef <- Resource.eval(C.ref[Option[Status]](None)) + start <- Resource.eval(F.monotonic) resp <- executeRequestAndRecordMetrics( client, ops, classifierF, req, statusRef, - start + start.toNanos ) } yield resp private def executeRequestAndRecordMetrics[F[_]]( client: Client[F], ops: MetricsOps[F], - classifierF: Request[F] => Option[String], + classifierF: Request[F] => F[Option[String]], req: Request[F], statusRef: Ref[F, Option[Status]], start: Long - )(implicit F: Sync[F], clock: Clock[F]): Resource[F, Response[F]] = + )(implicit F: Clock[F], C: Concurrent[F]): Resource[F, Response[F]] = (for { - _ <- Resource.make(ops.increaseActiveRequests(classifierF(req)))(_ => - ops.decreaseActiveRequests(classifierF(req))) - _ <- Resource.make(F.unit) { _ => - clock - .monotonic(TimeUnit.NANOSECONDS) + classifier <- Resource.eval(classifierF(req)) + _ <- Resource.make(ops.increaseActiveRequests(classifier))(_ => + ops.decreaseActiveRequests(classifier)) + _ <- Resource.make(C.unit) { _ => + F.monotonic .flatMap(now => statusRef.get.flatMap(oStatus => oStatus.traverse_(status => - ops.recordTotalTime(req.method, status, now - start, classifierF(req))))) + ops.recordTotalTime(req.method, status, now.toNanos - start, classifier)))) } resp <- client.run(req) _ <- Resource.eval(statusRef.set(Some(resp.status))) - end <- Resource.eval(clock.monotonic(TimeUnit.NANOSECONDS)) - _ <- Resource.eval(ops.recordHeadersTime(req.method, end - start, classifierF(req))) + end <- Resource.eval(F.monotonic) + _ <- Resource.eval(ops.recordHeadersTime(req.method, end.toNanos - start, classifier)) } yield resp).handleErrorWith { (e: Throwable) => - Resource.eval(registerError(start, ops, classifierF(req))(e) *> F.raiseError[Response[F]](e)) + Resource.eval( + classifierF(req).flatMap(registerError(start, ops, _)(e)) *> C.raiseError[Response[F]](e)) } private def registerError[F[_]](start: Long, ops: MetricsOps[F], classifier: Option[String])( - e: Throwable)(implicit F: Sync[F], clock: Clock[F]): F[Unit] = - clock - .monotonic(TimeUnit.NANOSECONDS) + e: Throwable)(implicit F: Clock[F], C: Concurrent[F]): F[Unit] = + F.monotonic .flatMap { now => if (e.isInstanceOf[TimeoutException]) - ops.recordAbnormalTermination(now - start, Timeout, classifier) + ops.recordAbnormalTermination(now.toNanos - start, Timeout, classifier) else - ops.recordAbnormalTermination(now - start, Error(e), classifier) + ops.recordAbnormalTermination(now.toNanos - start, Error(e), classifier) } } diff --git a/client/src/main/scala/org/http4s/client/middleware/RequestLogger.scala b/client/src/main/scala/org/http4s/client/middleware/RequestLogger.scala index edec499534f..78048413f93 100644 --- a/client/src/main/scala/org/http4s/client/middleware/RequestLogger.scala +++ b/client/src/main/scala/org/http4s/client/middleware/RequestLogger.scala @@ -19,7 +19,7 @@ package client package middleware import cats.effect._ -import cats.effect.concurrent.Ref +import cats.effect.Ref import cats.syntax.all._ import fs2._ import org.log4s.getLogger @@ -33,7 +33,7 @@ object RequestLogger { private def defaultLogAction[F[_]: Sync](s: String): F[Unit] = Sync[F].delay(logger.info(s)) - def apply[F[_]: Concurrent]( + def apply[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -47,7 +47,7 @@ object RequestLogger { )(logAction.getOrElse(defaultLogAction[F])) } - def logBodyText[F[_]: Concurrent]( + def logBodyText[F[_]: Async]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -61,7 +61,7 @@ object RequestLogger { )(logAction.getOrElse(defaultLogAction[F])) } - def customized[F[_]: Concurrent]( + def customized[F[_]: Async]( client: Client[F], logBody: Boolean = true, logAction: Option[String => F[Unit]] = None @@ -72,10 +72,11 @@ object RequestLogger { } private def impl[F[_]](client: Client[F], logBody: Boolean)(logMessage: Request[F] => F[Unit])( - implicit F: Concurrent[F]): Client[F] = + implicit F: Async[F]): Client[F] = Client { req => if (!logBody) - Resource.eval(logMessage(req)) *> client.run(req) + Resource.eval(logMessage(req)) *> client + .run(req) else Resource.suspend { Ref[F].of(Vector.empty[Chunk[Byte]]).map { vec => @@ -87,7 +88,7 @@ object RequestLogger { val changedRequest = req.withBodyStream( req.body // Cannot Be Done Asynchronously - Otherwise All Chunks May Not Be Appended Previous to Finalization - .observe(_.chunks.flatMap(s => Stream.eval_(vec.update(_ :+ s)))) + .observe(_.chunks.flatMap(s => Stream.exec(vec.update(_ :+ s)))) .onFinalizeWeak( logMessage(req.withBodyStream(newBody)).attempt .flatMap { @@ -104,13 +105,13 @@ object RequestLogger { val defaultRequestColor: String = Console.BLUE - def colored[F[_]: Concurrent]( + def colored[F[_]]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, color: String = defaultRequestColor, logAction: Option[String => F[Unit]] = None - )(client: Client[F]): Client[F] = + )(client: Client[F])(implicit F: Async[F]): Client[F] = customized(client, logBody, logAction) { request => import Console._ val methodColor = diff --git a/client/src/main/scala/org/http4s/client/middleware/ResponseLogger.scala b/client/src/main/scala/org/http4s/client/middleware/ResponseLogger.scala index 9285e18845c..8ba2209896b 100644 --- a/client/src/main/scala/org/http4s/client/middleware/ResponseLogger.scala +++ b/client/src/main/scala/org/http4s/client/middleware/ResponseLogger.scala @@ -19,7 +19,7 @@ package client package middleware import cats.effect._ -import cats.effect.concurrent.Ref +import cats.effect.Ref import cats.syntax.all._ import fs2._ import org.http4s.internal.{Logger => InternalLogger} @@ -33,7 +33,7 @@ object ResponseLogger { private def defaultLogAction[F[_]: Sync](s: String): F[Unit] = Sync[F].delay(logger.info(s)) - def apply[F[_]: Concurrent]( + def apply[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -47,7 +47,7 @@ object ResponseLogger { )(logAction.getOrElse(defaultLogAction[F])) } - def logBodyText[F[_]: Concurrent]( + def logBodyText[F[_]: Async]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -61,7 +61,7 @@ object ResponseLogger { )(logAction.getOrElse(defaultLogAction[F])) } - def customized[F[_]: Concurrent]( + def customized[F[_]: Async]( client: Client[F], logBody: Boolean = true, logAction: Option[String => F[Unit]] = None @@ -72,7 +72,7 @@ object ResponseLogger { } private def impl[F[_]](client: Client[F], logBody: Boolean)(logMessage: Response[F] => F[Unit])( - implicit F: Concurrent[F]): Client[F] = + implicit F: Async[F]): Client[F] = Client { req => client.run(req).flatMap { response => if (!logBody) @@ -84,7 +84,7 @@ object ResponseLogger { F.pure( response.copy(body = response.body // Cannot Be Done Asynchronously - Otherwise All Chunks May Not Be Appended Previous to Finalization - .observe(_.chunks.flatMap(s => Stream.eval_(vec.update(_ :+ s))))) + .observe(_.chunks.flatMap(s => Stream.exec(vec.update(_ :+ s))))) )) { _ => val newBody = Stream .eval(vec.get) @@ -108,7 +108,7 @@ object ResponseLogger { case Status.ServerError => Console.RED } - def colored[F[_]: Concurrent]( + def colored[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, diff --git a/client/src/main/scala/org/http4s/client/middleware/Retry.scala b/client/src/main/scala/org/http4s/client/middleware/Retry.scala index f5b62b715cb..0d9984a6e80 100644 --- a/client/src/main/scala/org/http4s/client/middleware/Retry.scala +++ b/client/src/main/scala/org/http4s/client/middleware/Retry.scala @@ -18,7 +18,7 @@ package org.http4s package client package middleware -import cats.effect.{Concurrent, Resource, Timer} +import cats.effect.kernel.{Resource, Temporal} import cats.syntax.all._ import org.http4s.Status._ import org.http4s.headers.{`Idempotency-Key`, `Retry-After`} @@ -29,42 +29,15 @@ import java.time.Instant import java.time.temporal.ChronoUnit import scala.concurrent.duration._ import scala.math.{min, pow, random} +import cats.effect.std.Hotswap object Retry { private[this] val logger = getLogger def apply[F[_]]( policy: RetryPolicy[F], - redactHeaderWhen: CIString => Boolean = Headers.SensitiveHeaders.contains)( - client: Client[F])(implicit F: Concurrent[F], T: Timer[F]): Client[F] = { - def prepareLoop(req: Request[F], attempts: Int): Resource[F, Response[F]] = - Resource.suspend[F, Response[F]](F.continual(client.run(req).allocated) { - case Right((response, dispose)) => - policy(req, Right(response), attempts) match { - case Some(duration) => - logger.info( - s"Request ${showRequest(req, redactHeaderWhen)} has failed on attempt #${attempts} with reason ${response.status}. Retrying after ${duration}.") - dispose >> F.pure( - nextAttempt(req, attempts, duration, response.headers.get[`Retry-After`])) - case None => - F.pure(Resource.make(F.pure(response))(_ => dispose)) - } - - case Left(e) => - policy(req, Left(e), attempts) match { - case Some(duration) => - // info instead of error(e), because e is not discarded - logger.info(e)( - s"Request threw an exception on attempt #$attempts. Retrying after $duration") - F.pure(nextAttempt(req, attempts, duration, None)) - case None => - logger.info(e)( - s"Request ${showRequest(req, redactHeaderWhen)} threw an exception on attempt #$attempts. Giving up." - ) - F.pure(Resource.eval(F.raiseError(e))) - } - }) - + redactHeaderWhen: CIString => Boolean = Headers.SensitiveHeaders.contains)(client: Client[F])( + implicit F: Temporal[F]): Client[F] = { def showRequest(request: Request[F], redactWhen: CIString => Boolean): String = { val headers = request.headers.redactSensitive(redactWhen).headers.mkString(",") val uri = request.uri.renderString @@ -76,7 +49,8 @@ object Retry { req: Request[F], attempts: Int, duration: FiniteDuration, - retryHeader: Option[`Retry-After`]): Resource[F, Response[F]] = { + retryHeader: Option[`Retry-After`], + hotswap: Hotswap[F, Either[Throwable, Response[F]]]): F[Response[F]] = { val headerDuration = retryHeader .map { h => @@ -87,10 +61,44 @@ object Retry { } .getOrElse(0L) val sleepDuration = headerDuration.seconds.max(duration) - Resource.eval(T.sleep(sleepDuration)) *> prepareLoop(req, attempts + 1) + F.sleep(sleepDuration) >> retryLoop(req, attempts + 1, hotswap) } - Client(prepareLoop(_, 1)) + def retryLoop( + req: Request[F], + attempts: Int, + hotswap: Hotswap[F, Either[Throwable, Response[F]]]): F[Response[F]] = + hotswap.swap(client.run(req).attempt).flatMap { + case Right(response) => + policy(req, Right(response), attempts) match { + case Some(duration) => + logger.info( + s"Request ${showRequest(req, redactHeaderWhen)} has failed on attempt #${attempts} with reason ${response.status}. Retrying after ${duration}.") + nextAttempt(req, attempts, duration, response.headers.get[`Retry-After`], hotswap) + case None => + F.pure(response) + } + + case Left(e) => + policy(req, Left(e), attempts) match { + case Some(duration) => + // info instead of error(e), because e is not discarded + logger.info(e)( + s"Request threw an exception on attempt #$attempts. Retrying after $duration") + nextAttempt(req, attempts, duration, None, hotswap) + case None => + logger.info(e)( + s"Request ${showRequest(req, redactHeaderWhen)} threw an exception on attempt #$attempts. Giving up." + ) + F.raiseError(e) + } + } + + Client { req => + Hotswap.create[F, Either[Throwable, Response[F]]].flatMap { hotswap => + Resource.eval(retryLoop(req, 1, hotswap)) + } + } } } @@ -137,10 +145,6 @@ object RetryPolicy { (req.method.isIdempotent || req.headers.get[`Idempotency-Key`].isDefined) && isErrorOrRetriableStatus(result) - @deprecated("Use defaultRetriable instead", "0.19.0") - def unsafeRetriable[F[_]](req: Request[F], result: Either[Throwable, Response[F]]): Boolean = - defaultRetriable(req, result) - /** Like [[defaultRetriable]], but returns true even if the request method * is not idempotent. This is useful if failed requests are assumed to * have not reached their destination, which is a dangerous assumption. diff --git a/client/src/main/scala/org/http4s/client/oauth1/ProtocolParameter.scala b/client/src/main/scala/org/http4s/client/oauth1/ProtocolParameter.scala index 004e9a6af5b..d1b52598830 100644 --- a/client/src/main/scala/org/http4s/client/oauth1/ProtocolParameter.scala +++ b/client/src/main/scala/org/http4s/client/oauth1/ProtocolParameter.scala @@ -16,13 +16,13 @@ package org.http4s.client.oauth1 +import cats.Show import cats.effect.Clock import cats.kernel.Order import cats.syntax.all._ -import cats.{Functor, Show} import org.http4s.client.oauth1.SignatureAlgorithm.Names.`HMAC-SHA1` - import java.util.concurrent.TimeUnit +import cats.Applicative sealed trait ProtocolParameter { val headerName: String @@ -56,8 +56,10 @@ object ProtocolParameter { } object Timestamp { - def now[F[_]](implicit F: Functor[F], clock: Clock[F]): F[Timestamp] = - clock.realTime(TimeUnit.SECONDS).map(seconds => Timestamp(seconds.toString)) + def now[F[_]](implicit F: Clock[F]): F[Timestamp] = { + implicit val FA: Applicative[F] = F.applicative + F.realTime.map(time => Timestamp(time.toUnit(TimeUnit.SECONDS).toString())) + } } case class Nonce(override val headerValue: String) extends ProtocolParameter { @@ -65,8 +67,10 @@ object ProtocolParameter { } object Nonce { - def now[F[_]](implicit F: Functor[F], clock: Clock[F]): F[Nonce] = - clock.monotonic(TimeUnit.NANOSECONDS).map(nanos => Nonce(nanos.toString)) + def now[F[_]](implicit F: Clock[F]): F[Nonce] = { + implicit val FA: Applicative[F] = F.applicative + F.monotonic.map(time => Nonce(time.toUnit(TimeUnit.NANOSECONDS).toString)) + } } case class Version(override val headerValue: String = "1.0") extends ProtocolParameter { diff --git a/client/src/test/scala/org/http4s/client/ClientRouteTestBattery.scala b/client/src/test/scala/org/http4s/client/ClientRouteTestBattery.scala index dbe4f6edc15..4f64f3f6803 100644 --- a/client/src/test/scala/org/http4s/client/ClientRouteTestBattery.scala +++ b/client/src/test/scala/org/http4s/client/ClientRouteTestBattery.scala @@ -19,59 +19,67 @@ package client import cats.effect._ import cats.syntax.all._ +import com.sun.net.httpserver._ import fs2._ import fs2.io._ +import java.io.PrintWriter +import java.util.Arrays import java.util.Locale -import javax.servlet.http.{HttpServlet, HttpServletRequest, HttpServletResponse} -import org.http4s.client.testroutes.GetRoutes import org.http4s.client.dsl.Http4sClientDsl +import org.http4s.client.testroutes.GetRoutes import org.http4s.dsl.io._ import org.http4s.implicits._ import org.http4s.multipart.{Multipart, Part} import org.typelevel.ci._ import scala.concurrent.duration._ -import java.util.Arrays abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Http4sClientDsl[IO] { val timeout = 20.seconds def clientResource: Resource[IO, Client[IO]] - def testServlet = - new HttpServlet { - override def doGet(req: HttpServletRequest, srv: HttpServletResponse): Unit = - req.getPathInfo match { + val testHandler: HttpHandler = exchange => + (exchange.getRequestMethod match { + case "GET" => + val path = exchange.getRequestURI.getPath + path match { case "/request-splitting" => - Option(req.getHeader("Evil")) match { - case None => srv.setStatus(200) - case Some(_) => srv.sendError(500) + val status = + if (exchange.getRequestHeaders.containsKey("Evil")) + 200 + else + 500 + IO.blocking { + exchange.sendResponseHeaders(status, -1L) + exchange.close() } case _ => - GetRoutes.getPaths.get(req.getRequestURI) match { + GetRoutes.getPaths.get(path) match { case Some(r) => - renderResponse(srv, r.unsafeRunSync()) - .unsafeRunSync() // We are outside the IO world - case None => srv.sendError(404) + r.flatMap(renderResponse(exchange, _)) + case None => + IO.blocking { + exchange.sendResponseHeaders(404, -1L) + exchange.close() + } } } + case "POST" => + IO.blocking { + exchange.sendResponseHeaders(200, 0L) + val s = scala.io.Source.fromInputStream(exchange.getRequestBody).mkString + val out = new PrintWriter(exchange.getResponseBody()) + out.print(s) + out.flush() + exchange.close() + } + }).start.unsafeRunAndForget() - override def doPost(req: HttpServletRequest, srv: HttpServletResponse): Unit = { - srv.setStatus(200) - val s = scala.io.Source.fromInputStream(req.getInputStream).mkString - srv.getWriter.print(s) - srv.getWriter.flush() - } - } - - // Need to override the context shift from munitCatsEffect - // This is only required for JettyClient - implicit val contextShift: ContextShift[IO] = Http4sSuite.TestContextShift - - val jetty = resourceSuiteFixture("server", JettyScaffold[IO](1, false, testServlet)) + val server = resourceSuiteFixture("server", ServerScaffold[IO](1, false, testHandler)) val client = resourceSuiteFixture("client", clientResource) test(s"$name Repeat a simple request") { - val address = jetty().addresses.head + val address = server().addresses.head val path = GetRoutes.SimplePath def fetchBody = @@ -87,7 +95,7 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt } test(s"$name POST an empty body") { - val address = jetty().addresses.head + val address = server().addresses.head val uri = Uri.fromString(s"http://${address.getHostName}:${address.getPort}/echo").yolo val req = POST(uri) val body = client().expect[String](req) @@ -95,7 +103,7 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt } test(s"$name POST a normal body") { - val address = jetty().addresses.head + val address = server().addresses.head val uri = Uri.fromString(s"http://${address.getHostName}:${address.getPort}/echo").yolo val req = POST("This is normal.", uri) val body = client().expect[String](req) @@ -103,7 +111,7 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt } test(s"$name POST a chunked body".flaky) { - val address = jetty().addresses.head + val address = server().addresses.head val uri = Uri.fromString(s"http://${address.getHostName}:${address.getPort}/echo").yolo val req = POST(Stream("This is chunked.").covary[IO], uri) val body = client().expect[String](req) @@ -111,7 +119,7 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt } test(s"$name POST a multipart body") { - val address = jetty().addresses.head + val address = server().addresses.head val uri = Uri.fromString(s"http://${address.getHostName}:${address.getPort}/echo").yolo val multipart = Multipart[IO](Vector(Part.formData("text", "This is text."))) val req = POST(multipart, uri).withHeaders(multipart.headers) @@ -119,21 +127,21 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt body.map(_.contains("This is text.")).assert } - test(s"$name Execute GET") { - val address = jetty().addresses.head - GetRoutes.getPaths.toList.traverse { case (path, expected) => + GetRoutes.getPaths.toList.foreach { case (path, expected) => + test(s"$name Execute GET $path") { + val address = server().addresses.head val name = address.getHostName val port = address.getPort val req = Request[IO](uri = Uri.fromString(s"http://$name:$port$path").yolo) client() .run(req) - .use(resp => expected.flatMap(expectedResponse => checkResponse(resp, expectedResponse))) + .use(resp => expected.flatMap(checkResponse(resp, _))) .assert } } test("Mitigates request splitting attack in URI path") { - val address = jetty().addresses.head + val address = server().addresses.head val name = address.getHostName val port = address.getPort val req = Request[IO]( @@ -146,7 +154,7 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt } test("Mitigates request splitting attack in URI RegName") { - val address = jetty().addresses.head + val address = server().addresses.head val name = address.getHostName val port = address.getPort val req = Request[IO](uri = Uri( @@ -157,7 +165,7 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt } test("Mitigates request splitting attack in field name") { - val address = jetty().addresses.head + val address = server().addresses.head val req = Request[IO]( uri = Uri.fromString(s"http://${address.getHostName}:${address.getPort}/request-splitting").yolo) @@ -166,7 +174,7 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt } test("Mitigates request splitting attack in field value") { - val address = jetty().addresses.head + val address = server().addresses.head val req = Request[IO]( uri = Uri.fromString(s"http://${address.getHostName}:${address.getPort}/request-splitting").yolo) @@ -192,15 +200,21 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt } yield true } - private def renderResponse(srv: HttpServletResponse, resp: Response[IO]): IO[Unit] = { - srv.setStatus(resp.status.code) - resp.headers.foreach { h => - srv.addHeader(h.name.toString, h.value) - } - resp.body - .through( - writeOutputStream[IO](IO.pure(srv.getOutputStream), testBlocker, closeAfterUse = false)) - .compile - .drain - } + private def renderResponse(exchange: HttpExchange, resp: Response[IO]): IO[Unit] = + IO(resp.headers.foreach { h => + if (h.name =!= headers.`Content-Length`.name) + exchange.getResponseHeaders.add(h.name.toString, h.value) + }) *> + IO.blocking { + // com.sun.net.httpserver warns on nocontent with a content lengt that is not -1 + val contentLength = + if (resp.status.code == NoContent.code) -1L + else resp.contentLength.getOrElse(0L) + exchange.sendResponseHeaders(resp.status.code, contentLength) + } *> + resp.body + .through(writeOutputStream[IO](IO.pure(exchange.getResponseBody), closeAfterUse = false)) + .compile + .drain + .guarantee(IO(exchange.close())) } diff --git a/client/src/test/scala/org/http4s/client/ClientSuite.scala b/client/src/test/scala/org/http4s/client/ClientSuite.scala index a9748e59784..a556cacce8f 100644 --- a/client/src/test/scala/org/http4s/client/ClientSuite.scala +++ b/client/src/test/scala/org/http4s/client/ClientSuite.scala @@ -17,18 +17,17 @@ package org.http4s package client -import cats.effect.concurrent.Deferred +import cats.effect.kernel.Deferred import cats.effect._ import cats.syntax.all._ -import java.io.IOException import org.http4s.dsl.Http4sDsl import org.http4s.headers.Host import org.http4s.server.middleware.VirtualHost import org.http4s.server.middleware.VirtualHost.exact -import org.http4s.syntax.AllSyntax +import org.http4s.syntax.all._ -class ClientSuite extends Http4sSuite with Http4sDsl[IO] with AllSyntax { - private val app = HttpApp[IO] { r => +class ClientSpec extends Http4sSuite with Http4sDsl[IO] { + private val app = HttpApp[IO] { case r => Response[IO](Ok).withEntity(r.body).pure[IO] } val client: Client[IO] = Client.fromHttpApp(app) @@ -46,10 +45,8 @@ class ClientSuite extends Http4sSuite with Http4sDsl[IO] with AllSyntax { client.run(req).use(IO.pure).flatMap(_.as[String]) } .attempt - .map { - case Left(e: IOException) => e.getMessage == "response was disposed" - case _ => false - } + .map(_.left.toOption.get.getMessage) + .assertEquals("response was disposed") } test("mock client should include a Host header in requests whose URIs are absolute") { @@ -94,17 +91,17 @@ class ClientSuite extends Http4sSuite with Http4sDsl[IO] with AllSyntax { val cancelClient = Client.fromHttpApp(routes.orNotFound) - Deferred[IO, ExitCase[Throwable]] - .flatTap { exitCase => + Deferred[IO, Outcome[IO, Throwable, String]] + .flatTap { outcome => cancelClient .expect[String](Request[IO](GET, uri"https://http4s.org/")) - .guaranteeCase(exitCase.complete) + .guaranteeCase(oc => outcome.complete(oc).void) .start .flatTap(fiber => cancelSignal.get >> fiber.cancel) // don't cancel until the returned resource is in use } .flatMap(_.get) } - .assertEquals(ExitCase.Canceled) + .assertEquals(Outcome.canceled[IO, Throwable, String]) } } diff --git a/client/src/test/scala/org/http4s/client/ClientSyntaxSuite.scala b/client/src/test/scala/org/http4s/client/ClientSyntaxSuite.scala index 7f35c76fd01..3769a4b32ea 100644 --- a/client/src/test/scala/org/http4s/client/ClientSyntaxSuite.scala +++ b/client/src/test/scala/org/http4s/client/ClientSyntaxSuite.scala @@ -18,7 +18,7 @@ package org.http4s package client import cats.effect._ -import cats.effect.concurrent.Ref +import cats.effect.kernel.Ref import cats.syntax.all._ import fs2._ import org.http4s.Method._ @@ -283,7 +283,7 @@ class ClientSyntaxSuite extends Http4sSuite with Http4sClientDsl[IO] { test("Client should stream returns a stream") { client .stream(req) - .flatMap(_.body.through(fs2.text.utf8Decode)) + .flatMap(_.body.through(fs2.text.utf8.decode)) .compile .toVector .assertEquals(Vector("hello")) diff --git a/client/src/test/scala/org/http4s/client/JavaNetClientSuite.scala b/client/src/test/scala/org/http4s/client/JavaNetClientSuite.scala index 30f935b813f..f6137002543 100644 --- a/client/src/test/scala/org/http4s/client/JavaNetClientSuite.scala +++ b/client/src/test/scala/org/http4s/client/JavaNetClientSuite.scala @@ -19,6 +19,6 @@ package client import cats.effect.IO -class JavaNetClientSuite extends ClientRouteTestBattery("JavaNetClient") { - def clientResource = JavaNetClientBuilder[IO](testBlocker).resource +class JavaNetClientSpec extends ClientRouteTestBattery("JavaNetClient") { + def clientResource = JavaNetClientBuilder[IO].resource } diff --git a/client/src/test/scala/org/http4s/client/JettyScaffold.scala b/client/src/test/scala/org/http4s/client/JettyScaffold.scala deleted file mode 100644 index 81fe68fa787..00000000000 --- a/client/src/test/scala/org/http4s/client/JettyScaffold.scala +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2014 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.client - -import cats.effect.{Resource, Sync} -import java.net.{InetSocketAddress} -import java.security.{KeyStore, Security} -import javax.net.ssl.{KeyManagerFactory, SSLContext} -import javax.servlet.http.HttpServlet -import org.eclipse.jetty.server.{Server => JServer, _} -import org.eclipse.jetty.servlet.{ServletContextHandler, ServletHolder} -import org.eclipse.jetty.util.ssl.SslContextFactory - -object JettyScaffold { - def apply[F[_]](num: Int, secure: Boolean, testServlet: HttpServlet)(implicit - F: Sync[F]): Resource[F, JettyScaffold] = - Resource.make(F.delay { - val scaffold = new JettyScaffold(num, secure) - scaffold.startServers(testServlet) - })(s => F.delay(s.stopServers())) -} - -class JettyScaffold private (num: Int, secure: Boolean) { - private var servers = Vector.empty[JServer] - var addresses = Vector.empty[InetSocketAddress] - - def startServers(testServlet: HttpServlet): this.type = { - val res = (0 until num).map { _ => - val server = new JServer() - val context = new ServletContextHandler() - context.setContextPath("/") - context.addServlet(new ServletHolder("Test-servlet", testServlet), "/*") - - server.setHandler(context) - - val connector = - if (secure) { - val ksStream = this.getClass.getResourceAsStream("/server.jks") - val ks = KeyStore.getInstance("JKS") - ks.load(ksStream, "password".toCharArray) - ksStream.close() - - val kmf = KeyManagerFactory.getInstance( - Option(Security.getProperty("ssl.KeyManagerFactory.algorithm")) - .getOrElse(KeyManagerFactory.getDefaultAlgorithm)) - - kmf.init(ks, "secure".toCharArray) - - val sslContext = SSLContext.getInstance("TLS") - sslContext.init(kmf.getKeyManagers, null, null) - - val sslContextFactory = new SslContextFactory.Server() - sslContextFactory.setSslContext(sslContext) - - val httpsConfig = new HttpConfiguration() - httpsConfig.setSecureScheme("https") - httpsConfig.addCustomizer(new SecureRequestCustomizer()) - val connectionFactory = new HttpConnectionFactory(httpsConfig) - new ServerConnector( - server, - new SslConnectionFactory( - sslContextFactory, - org.eclipse.jetty.http.HttpVersion.HTTP_1_1.asString()), - connectionFactory) - } else new ServerConnector(server) - connector.setPort(0) - server.addConnector(connector) - server.start() - - val address = new InetSocketAddress( - "localhost", - server.getConnectors.head.asInstanceOf[ServerConnector].getLocalPort) - - (address, server) - }.toVector - - servers = res.map(_._2) - addresses = res.map(_._1) - - this - } - - def stopServers(): Unit = servers.foreach(_.stop()) -} diff --git a/client/src/test/scala/org/http4s/client/ServerScaffold.scala b/client/src/test/scala/org/http4s/client/ServerScaffold.scala new file mode 100644 index 00000000000..75754b3635f --- /dev/null +++ b/client/src/test/scala/org/http4s/client/ServerScaffold.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2014 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.client + +import cats.effect.{Resource, Sync} +import com.sun.net.httpserver._ +import java.net.InetSocketAddress +import java.security.{KeyStore, Security} +import javax.net.ssl.{KeyManagerFactory, SSLContext} + +object ServerScaffold { + def apply[F[_]](num: Int, secure: Boolean, testHandler: HttpHandler)(implicit + F: Sync[F]): Resource[F, ServerScaffold] = + Resource.make(F.delay { + val scaffold = new ServerScaffold(num, secure) + scaffold.startServers(testHandler) + })(s => F.delay(s.stopServers())) +} + +class ServerScaffold private (num: Int, secure: Boolean) { + private var servers = Vector.empty[HttpServer] + var addresses = Vector.empty[InetSocketAddress] + + def startServers(testHandler: HttpHandler): this.type = { + val res = (0 until num).map { _ => + val server: HttpServer = + if (secure) { + + val ksStream = this.getClass.getResourceAsStream("/server.jks") + val ks = KeyStore.getInstance("JKS") + ks.load(ksStream, "password".toCharArray) + ksStream.close() + + val kmf = KeyManagerFactory.getInstance( + Option(Security.getProperty("ssl.KeyManagerFactory.algorithm")) + .getOrElse(KeyManagerFactory.getDefaultAlgorithm)) + + kmf.init(ks, "secure".toCharArray) + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(kmf.getKeyManagers, null, null) + + val server = HttpsServer.create() + server.setHttpsConfigurator(new HttpsConfigurator(sslContext)) + + server + } else HttpServer.create() + + val context = server.createContext("/") + context.setHandler(testHandler) + + server.bind(new InetSocketAddress("localhost", 0), 0) + server.start() + (server.getAddress, server) + }.toVector + + servers = res.map(_._2) + addresses = res.map(_._1) + + this + } + + def stopServers(): Unit = servers.foreach(_.stop(10)) +} diff --git a/client/src/test/scala/org/http4s/client/middleware/FollowRedirectSuite.scala b/client/src/test/scala/org/http4s/client/middleware/FollowRedirectSuite.scala index b5909c85cbf..47f76ddfc09 100644 --- a/client/src/test/scala/org/http4s/client/middleware/FollowRedirectSuite.scala +++ b/client/src/test/scala/org/http4s/client/middleware/FollowRedirectSuite.scala @@ -19,7 +19,7 @@ package client package middleware import cats.effect._ -import cats.effect.concurrent.Semaphore +import cats.effect.std.Semaphore import cats.syntax.all._ import java.util.concurrent.atomic._ import org.http4s.client.dsl.Http4sClientDsl diff --git a/client/src/test/scala/org/http4s/client/middleware/GZipSuite.scala b/client/src/test/scala/org/http4s/client/middleware/GZipSuite.scala index b9134d93cd9..54c218f9549 100644 --- a/client/src/test/scala/org/http4s/client/middleware/GZipSuite.scala +++ b/client/src/test/scala/org/http4s/client/middleware/GZipSuite.scala @@ -21,6 +21,7 @@ package middleware import cats.effect.IO import org.http4s.dsl.io._ import org.http4s.headers.{`Content-Encoding`, `Content-Length`} +import org.http4s.syntax.all._ class GZipSuite extends Http4sSuite { private val service = server.middleware.GZip(HttpApp[IO] { @@ -33,7 +34,7 @@ class GZipSuite extends Http4sSuite { test("Client Gzip should return data correctly") { gzipClient - .get(Uri.unsafeFromString("/gziptest")) { response => + .get(uri"/gziptest") { response => assert(response.status == Status.Ok) assert(response.headers.get[`Content-Encoding`].isEmpty) assert(response.headers.get[`Content-Length`].isEmpty) @@ -46,7 +47,7 @@ class GZipSuite extends Http4sSuite { } test("Client Gzip should not decompress when the response body is empty") { - val request = Request[IO](method = Method.HEAD, uri = Uri.unsafeFromString("/gziptest")) + val request = Request[IO](method = Method.HEAD, uri = uri"/gziptest") gzipClient .run(request) .use { response => diff --git a/client/src/test/scala/org/http4s/client/middleware/LoggerSuite.scala b/client/src/test/scala/org/http4s/client/middleware/LoggerSuite.scala index 1dd1a7f5394..50801d9d06b 100644 --- a/client/src/test/scala/org/http4s/client/middleware/LoggerSuite.scala +++ b/client/src/test/scala/org/http4s/client/middleware/LoggerSuite.scala @@ -39,7 +39,7 @@ class LoggerSuite extends Http4sSuite { def testResource = getClass.getResourceAsStream("/testresource.txt") def body: EntityBody[IO] = - readInputStream[IO](IO.pure(testResource), 4096, testBlocker) + readInputStream[IO](IO.pure(testResource), 4096) val expectedBody: String = Source.fromInputStream(testResource).mkString diff --git a/client/src/test/scala/org/http4s/client/middleware/RetrySuite.scala b/client/src/test/scala/org/http4s/client/middleware/RetrySuite.scala index 4fdc32db5d1..9413f11d2a6 100644 --- a/client/src/test/scala/org/http4s/client/middleware/RetrySuite.scala +++ b/client/src/test/scala/org/http4s/client/middleware/RetrySuite.scala @@ -18,17 +18,17 @@ package org.http4s package client package middleware -import cats.effect.concurrent.{Ref, Semaphore} +import cats.effect.kernel.Ref +import cats.effect.std.Semaphore import cats.effect.{IO, Resource} import cats.syntax.all._ import fs2.Stream import org.http4s.dsl.io._ import org.http4s.headers.`Idempotency-Key` -import org.http4s.laws.discipline.ArbitraryInstances._ +import org.http4s.laws.discipline.ArbitraryInstances.http4sTestingArbitraryForStatus import org.http4s.syntax.all._ import org.scalacheck.effect.PropF import org.scalacheck.Gen - import scala.concurrent.duration._ class RetrySuite extends Http4sSuite { diff --git a/client/src/test/scala/org/http4s/client/oauth1/OAuthSuite.scala b/client/src/test/scala/org/http4s/client/oauth1/OAuthSuite.scala index 4d391234c90..09b78b97d72 100644 --- a/client/src/test/scala/org/http4s/client/oauth1/OAuthSuite.scala +++ b/client/src/test/scala/org/http4s/client/oauth1/OAuthSuite.scala @@ -17,7 +17,7 @@ package org.http4s.client.oauth1 import cats.data.NonEmptyList -import cats.effect.{IO, Timer} +import cats.effect.IO import org.http4s.Credentials.AuthParams import org.http4s._ import org.http4s.client.oauth1 @@ -32,7 +32,6 @@ import java.nio.charset.StandardCharsets.UTF_8 class OAuthSuite extends Http4sSuite { // some params taken from http://oauth.net/core/1.0/#anchor30, others from // http://tools.ietf.org/html/rfc5849 - implicit val timer: Timer[IO] = Http4sSuite.TestTimer val Right(uri) = Uri.fromString("http://photos.example.net/photos") val consumer = oauth1.Consumer("dpf43f3p2l4k3l03", "kd94hf93k423kf44") diff --git a/client/src/test/scala/org/http4s/client/testroutes/GetRoutes.scala b/client/src/test/scala/org/http4s/client/testroutes/GetRoutes.scala index eb8ca70939b..41771f52f13 100644 --- a/client/src/test/scala/org/http4s/client/testroutes/GetRoutes.scala +++ b/client/src/test/scala/org/http4s/client/testroutes/GetRoutes.scala @@ -32,14 +32,14 @@ object GetRoutes { val EmptyNotFoundPath = "/empty-not-found" val InternalServerErrorPath = "/internal-server-error" - def getPaths(implicit timer: Timer[IO]): Map[String, IO[Response[IO]]] = + def getPaths(implicit F: Temporal[IO]): Map[String, IO[Response[IO]]] = Map( SimplePath -> Response[IO](Ok).withEntity("simple path").pure[IO], ChunkedPath -> Response[IO](Ok) .withEntity(Stream.emits("chunk".toSeq.map(_.toString)).covary[IO]) .pure[IO], DelayedPath -> - timer.sleep(1.second) *> + F.sleep(1.second) *> Response[IO](Ok).withEntity("delayed path").pure[IO], NoContentPath -> Response[IO](NoContent).pure[IO], NotFoundPath -> Response[IO](NotFound).withEntity("not found").pure[IO], diff --git a/core/src/main/scala/org/http4s/AuthedRoutes.scala b/core/src/main/scala/org/http4s/AuthedRoutes.scala index 60dabbab569..0fc7b59ee7b 100644 --- a/core/src/main/scala/org/http4s/AuthedRoutes.scala +++ b/core/src/main/scala/org/http4s/AuthedRoutes.scala @@ -16,7 +16,7 @@ package org.http4s -import cats.{Applicative, Defer} +import cats.{Applicative, Monad} import cats.data.{Kleisli, OptionT} import cats.syntax.all._ @@ -32,8 +32,8 @@ object AuthedRoutes { * @return an [[AuthedRoutes]] that wraps `run` */ def apply[T, F[_]](run: AuthedRequest[F, T] => OptionT[F, Response[F]])(implicit - F: Defer[F]): AuthedRoutes[T, F] = - Kleisli(req => OptionT(F.defer(run(req).value))) + F: Monad[F]): AuthedRoutes[T, F] = + Kleisli(req => OptionT(F.unit >> run(req).value)) /** Lifts a partial function into an [[AuthedRoutes]]. The application of the * partial function is suspended in `F` to permit more efficient combination @@ -45,9 +45,8 @@ object AuthedRoutes { * wherever `pf` is defined, an `OptionT.none` wherever it is not */ def of[T, F[_]](pf: PartialFunction[AuthedRequest[F, T], F[Response[F]]])(implicit - F: Defer[F], - FA: Applicative[F]): AuthedRoutes[T, F] = - Kleisli(req => OptionT(F.defer(pf.lift(req).sequence))) + FA: Monad[F]): AuthedRoutes[T, F] = + Kleisli(req => OptionT(FA.unit >> pf.lift(req).sequence)) /** The empty service (all requests fallthrough). * diff --git a/core/src/main/scala/org/http4s/Charset.scala b/core/src/main/scala/org/http4s/Charset.scala index 26e69cbf9a0..f5bef2535db 100644 --- a/core/src/main/scala/org/http4s/Charset.scala +++ b/core/src/main/scala/org/http4s/Charset.scala @@ -17,9 +17,6 @@ import org.http4s.internal.CollectionCompat.CollectionConverters._ import org.http4s.util._ final case class Charset private (nioCharset: NioCharset) extends Renderable { - @deprecated("Use `Accept-Charset`.isSatisfiedBy(charset)", "0.16.1") - def satisfies(charsetRange: CharsetRange): Boolean = charsetRange.isSatisfiedBy(this) - def withQuality(q: QValue): CharsetRange.Atom = CharsetRange.Atom(this, q) def toRange: CharsetRange.Atom = withQuality(QValue.One) diff --git a/core/src/main/scala/org/http4s/CharsetRange.scala b/core/src/main/scala/org/http4s/CharsetRange.scala index edbb84e8690..2b33d25d2bf 100644 --- a/core/src/main/scala/org/http4s/CharsetRange.scala +++ b/core/src/main/scala/org/http4s/CharsetRange.scala @@ -23,9 +23,6 @@ sealed abstract class CharsetRange extends HasQValue with Renderable { def qValue: QValue def withQValue(q: QValue): CharsetRange - @deprecated("Use `isSatisfiedBy` on the `Accept-Charset` header", "0.16.1") - def isSatisfiedBy(charset: Charset): Boolean - /** True if this charset range matches the charset. * * @since 0.16.1 @@ -41,9 +38,6 @@ object CharsetRange { sealed case class `*`(qValue: QValue) extends CharsetRange { final override def withQValue(q: QValue): CharsetRange.`*` = copy(qValue = q) - @deprecated("Use `Accept-Charset`.isSatisfiedBy(charset)", "0.16.1") - final def isSatisfiedBy(charset: Charset): Boolean = qValue.isAcceptable - final def render(writer: Writer): writer.type = writer << "*" << qValue } @@ -53,9 +47,6 @@ object CharsetRange { extends CharsetRange { override def withQValue(q: QValue): CharsetRange.Atom = copy(qValue = q) - @deprecated("Use `Accept-Charset`.isSatisfiedBy(charset)", "0.16.1") - def isSatisfiedBy(charset: Charset): Boolean = qValue.isAcceptable && this.charset == charset - def render(writer: Writer): writer.type = writer << charset << qValue } diff --git a/core/src/main/scala/org/http4s/ContentCoding.scala b/core/src/main/scala/org/http4s/ContentCoding.scala index 08a859dba1b..15ab209ca76 100644 --- a/core/src/main/scala/org/http4s/ContentCoding.scala +++ b/core/src/main/scala/org/http4s/ContentCoding.scala @@ -24,14 +24,6 @@ class ContentCoding private (val coding: String, override val qValue: QValue = Q with Renderable { def withQValue(q: QValue): ContentCoding = new ContentCoding(coding, q) - @deprecated("Use `Accept-Encoding`.isSatisfiedBy(encoding)", "0.16.1") - def satisfies(encoding: ContentCoding): Boolean = encoding.satisfiedBy(this) - - @deprecated("Use `Accept-Encoding`.isSatisfiedBy(encoding)", "0.16.1") - def satisfiedBy(encoding: ContentCoding): Boolean = - (this === ContentCoding.`*` || this.coding.equalsIgnoreCase(encoding.coding)) && - qValue.isAcceptable && encoding.qValue.isAcceptable - def matches(encoding: ContentCoding): Boolean = this === ContentCoding.`*` || this.coding.equalsIgnoreCase(encoding.coding) diff --git a/core/src/main/scala/org/http4s/ContextRoutes.scala b/core/src/main/scala/org/http4s/ContextRoutes.scala index 6b2d6f45d3b..785228628a0 100644 --- a/core/src/main/scala/org/http4s/ContextRoutes.scala +++ b/core/src/main/scala/org/http4s/ContextRoutes.scala @@ -17,7 +17,7 @@ package org.http4s import cats.data.{Kleisli, OptionT} -import cats.{Applicative, Defer} +import cats.{Applicative, Monad} import cats.syntax.all._ object ContextRoutes { @@ -32,8 +32,8 @@ object ContextRoutes { * @return an [[ContextRoutes]] that wraps `run` */ def apply[T, F[_]](run: ContextRequest[F, T] => OptionT[F, Response[F]])(implicit - F: Defer[F]): ContextRoutes[T, F] = - Kleisli(req => OptionT(F.defer(run(req).value))) + F: Monad[F]): ContextRoutes[T, F] = + Kleisli(req => OptionT(F.unit >> run(req).value)) /** Lifts a partial function into an [[ContextRoutes]]. The application of the * partial function is suspended in `F` to permit more efficient combination @@ -45,9 +45,8 @@ object ContextRoutes { * wherever `pf` is defined, an `OptionT.none` wherever it is not */ def of[T, F[_]](pf: PartialFunction[ContextRequest[F, T], F[Response[F]]])(implicit - F: Defer[F], - FA: Applicative[F]): ContextRoutes[T, F] = - Kleisli(req => OptionT(F.defer(pf.lift(req).sequence))) + F: Monad[F]): ContextRoutes[T, F] = + Kleisli(req => OptionT(Applicative[F].unit >> pf.lift(req).sequence)) /** Lifts a partial function into an [[ContextRoutes]]. The application of the * partial function is not suspended in `F`, unlike [[of]]. This allows for less diff --git a/core/src/main/scala/org/http4s/EntityDecoder.scala b/core/src/main/scala/org/http4s/EntityDecoder.scala index 76774308bbf..63412d9b13a 100644 --- a/core/src/main/scala/org/http4s/EntityDecoder.scala +++ b/core/src/main/scala/org/http4s/EntityDecoder.scala @@ -17,15 +17,16 @@ package org.http4s import cats.{Applicative, Functor, Monad, SemigroupK} -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.Concurrent import cats.syntax.all._ import fs2._ -import fs2.io.file.writeAll +import fs2.io.file.{Files, Path} import java.io.File import org.http4s.multipart.{Multipart, MultipartDecoder} import scodec.bits.ByteVector import scala.annotation.implicitNotFound +import cats.effect.Resource /** A type that can be used to decode a [[Message]] * EntityDecoder is used to attempt to decode a [[Message]] returning the @@ -190,81 +191,144 @@ object EntityDecoder { } /** Helper method which simply gathers the body into a single Chunk */ - def collectBinary[F[_]: Sync](m: Media[F]): DecodeResult[F, Chunk[Byte]] = - DecodeResult.success(m.body.chunks.compile.toVector.map(Chunk.concatBytes)) + def collectBinary[F[_]: Concurrent](m: Media[F]): DecodeResult[F, Chunk[Byte]] = + DecodeResult.success(m.body.chunks.compile.toVector.map(bytes => Chunk.concat(bytes))) /** Helper method which simply gathers the body into a single ByteVector */ - private def collectByteVector[F[_]: Sync](m: Media[F]): DecodeResult[F, ByteVector] = + private def collectByteVector[F[_]: Concurrent](m: Media[F]): DecodeResult[F, ByteVector] = DecodeResult.success(m.body.compile.toVector.map(ByteVector(_))) /** Decodes a message to a String */ def decodeText[F[_]]( - m: Media[F])(implicit F: Sync[F], defaultCharset: Charset = DefaultCharset): F[String] = + m: Media[F])(implicit F: Concurrent[F], defaultCharset: Charset = DefaultCharset): F[String] = m.bodyText.compile.string /////////////////// Instances ////////////////////////////////////////////// /** Provides a mechanism to fail decoding */ - def error[F[_], T](t: Throwable)(implicit F: Sync[F]): EntityDecoder[F, T] = + def error[F[_], T](t: Throwable)(implicit F: Concurrent[F]): EntityDecoder[F, T] = new EntityDecoder[F, T] { override def decode(m: Media[F], strict: Boolean): DecodeResult[F, T] = DecodeResult(m.body.compile.drain *> F.raiseError(t)) override def consumes: Set[MediaRange] = Set.empty } - implicit def binary[F[_]: Sync]: EntityDecoder[F, Chunk[Byte]] = + implicit def binary[F[_]: Concurrent]: EntityDecoder[F, Chunk[Byte]] = EntityDecoder.decodeBy(MediaRange.`*/*`)(collectBinary[F]) - @deprecated("Use `binary` instead", "0.19.0-M2") - def binaryChunk[F[_]: Sync]: EntityDecoder[F, Chunk[Byte]] = - binary[F] - - implicit def byteArrayDecoder[F[_]: Sync]: EntityDecoder[F, Array[Byte]] = + implicit def byteArrayDecoder[F[_]: Concurrent]: EntityDecoder[F, Array[Byte]] = binary.map(_.toArray) - implicit def byteVector[F[_]: Sync]: EntityDecoder[F, ByteVector] = + implicit def byteVector[F[_]: Concurrent]: EntityDecoder[F, ByteVector] = EntityDecoder.decodeBy(MediaRange.`*/*`)(collectByteVector[F]) implicit def text[F[_]](implicit - F: Sync[F], + F: Concurrent[F], defaultCharset: Charset = DefaultCharset): EntityDecoder[F, String] = EntityDecoder.decodeBy(MediaRange.`text/*`)(msg => collectBinary(msg).map(chunk => new String(chunk.toArray, msg.charset.getOrElse(defaultCharset).nioCharset))) - implicit def charArrayDecoder[F[_]: Sync]: EntityDecoder[F, Array[Char]] = + implicit def charArrayDecoder[F[_]: Concurrent]: EntityDecoder[F, Array[Char]] = text.map(_.toArray) // File operations - def binFile[F[_]](file: File, blocker: Blocker)(implicit - F: Sync[F], - cs: ContextShift[F]): EntityDecoder[F, File] = + def binFile[F[_]: Files: Concurrent](file: File): EntityDecoder[F, File] = EntityDecoder.decodeBy(MediaRange.`*/*`) { msg => - val pipe = writeAll[F](file.toPath, blocker) + val pipe = Files[F].writeAll(Path.fromNioPath(file.toPath)) DecodeResult.success(msg.body.through(pipe).compile.drain).map(_ => file) } - def textFile[F[_]](file: File, blocker: Blocker)(implicit - F: Sync[F], - cs: ContextShift[F]): EntityDecoder[F, File] = + def textFile[F[_]: Files: Concurrent](file: File): EntityDecoder[F, File] = EntityDecoder.decodeBy(MediaRange.`text/*`) { msg => - val pipe = writeAll[F](file.toPath, blocker) + val pipe = Files[F].writeAll(Path.fromNioPath(file.toPath)) DecodeResult.success(msg.body.through(pipe).compile.drain).map(_ => file) } - implicit def multipart[F[_]: Sync]: EntityDecoder[F, Multipart[F]] = + implicit def multipart[F[_]: Concurrent]: EntityDecoder[F, Multipart[F]] = MultipartDecoder.decoder - def mixedMultipart[F[_]: Sync: ContextShift]( - blocker: Blocker, + /** Multipart decoder that streams all parts past a threshold + * (anything above `maxSizeBeforeWrite`) into a temporary file. + * The decoder is only valid inside the `Resource` scope; once + * the `Resource` is released, all the created files are deleted. + * + * Note that no files are deleted until the `Resource` is released. + * Thus, sharing and reusing the resulting `EntityDecoder` is not + * recommended, and can lead to disk space leaks. + * + * The intended way to use this is as follows: + * + * {{{ + * mixedMultipartResource[F]() + * .flatTap(request.decodeWith(_, strict = true)) + * .use { multipart => + * // Use the decoded entity + * } + * }}} + * + * @param headerLimit the max size for the headers, in bytes. This is required as + * headers are strictly evaluated and parsed. + * @param maxSizeBeforeWrite the maximum size of a particular part before writing to a file is triggered + * @param maxParts the maximum number of parts this decoder accepts. NOTE: this also may mean that a body that doesn't + * conform perfectly to the spec (i.e isn't terminated properly) but has a lot of parts might + * be parsed correctly, despite the total body being malformed due to not conforming to the multipart + * spec. You can control this by `failOnLimit`, by setting it to true if you want to raise + * an error if sending too many parts to a particular endpoint + * @param failOnLimit Fail if `maxParts` is exceeded _during_ multipart parsing. + * @param chunkSize the size of chunks created when reading data from temporary files. + * @return A supervised multipart decoder. + */ + def mixedMultipartResource[F[_]: Concurrent: Files]( + headerLimit: Int = 1024, + maxSizeBeforeWrite: Int = 52428800, + maxParts: Int = 50, + failOnLimit: Boolean = false, + chunkSize: Int = 8192 + ): Resource[F, EntityDecoder[F, Multipart[F]]] = + MultipartDecoder.mixedMultipartResource( + headerLimit, + maxSizeBeforeWrite, + maxParts, + failOnLimit, + chunkSize) + + /** Multipart decoder that streams all parts past a threshold + * (anything above maxSizeBeforeWrite) into a temporary file. + * + * Note: (BIG NOTE) Using this decoder for multipart decoding is good for the sake of + * not holding all information in memory, as it will never have more than + * `maxSizeBeforeWrite` in memory before writing to a temporary file. On top of this, + * you can gate the # of parts to further stop the quantity of parts you can have. + * That said, because after a threshold it writes into a temporary file, given + * bincompat reasons on 0.18.x, there is no way to make a distinction about which `Part[F]` + * is a stream reference to a file or not. Thus, consumers using this decoder + * should drain all `Part[F]` bodies if they were decoded correctly. That said, + * this decoder gives you more control about how many part bodies it parses in the first place, thus you can have + * more fine-grained control about how many parts you accept. + * + * @param headerLimit the max size for the headers, in bytes. This is required as + * headers are strictly evaluated and parsed. + * @param maxSizeBeforeWrite the maximum size of a particular part before writing to a file is triggered + * @param maxParts the maximum number of parts this decoder accepts. NOTE: this also may mean that a body that doesn't + * conform perfectly to the spec (i.e isn't terminated properly) but has a lot of parts might + * be parsed correctly, despite the total body being malformed due to not conforming to the multipart + * spec. You can control this by `failOnLimit`, by setting it to true if you want to raise + * an error if sending too many parts to a particular endpoint + * @param failOnLimit Fail if `maxParts` is exceeded _during_ multipart parsing. + * @return A multipart/form-data encoded vector of parts with some part bodies held in + * temporary files. + */ + @deprecated("Use mixedMultipartResource", "0.23") + def mixedMultipart[F[_]: Concurrent: Files]( headerLimit: Int = 1024, maxSizeBeforeWrite: Int = 52428800, maxParts: Int = 50, failOnLimit: Boolean = false): EntityDecoder[F, Multipart[F]] = - MultipartDecoder.mixedMultipart(blocker, headerLimit, maxSizeBeforeWrite, maxParts, failOnLimit) + MultipartDecoder.mixedMultipart(headerLimit, maxSizeBeforeWrite, maxParts, failOnLimit) /** An entity decoder that ignores the content and returns unit. */ - implicit def void[F[_]: Sync]: EntityDecoder[F, Unit] = + implicit def void[F[_]: Concurrent]: EntityDecoder[F, Unit] = EntityDecoder.decodeBy(MediaRange.`*/*`) { msg => DecodeResult.success(msg.body.drain.compile.drain) } diff --git a/core/src/main/scala/org/http4s/EntityEncoder.scala b/core/src/main/scala/org/http4s/EntityEncoder.scala index 7bd11feed92..b961431dfb1 100644 --- a/core/src/main/scala/org/http4s/EntityEncoder.scala +++ b/core/src/main/scala/org/http4s/EntityEncoder.scala @@ -18,10 +18,10 @@ package org.http4s import cats.{Contravariant, Show} import cats.data.NonEmptyList -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.Sync import cats.syntax.all._ import fs2.{Chunk, Stream} -import fs2.io.file.readAll +import fs2.io.file.{Files, Path => Fs2Path} import fs2.io.readInputStream import java.io._ import java.nio.CharBuffer @@ -99,7 +99,7 @@ object EntityEncoder { charset: Charset = DefaultCharset, show: Show[A]): EntityEncoder[F, A] = { val hdr = `Content-Type`(MediaType.text.plain).withCharset(charset) - simple[F, A](hdr)(a => Chunk.bytes(show.show(a).getBytes(charset.nioCharset))) + simple[F, A](hdr)(a => Chunk.array(show.show(a).getBytes(charset.nioCharset))) } def emptyEncoder[F[_], A]: EntityEncoder[F, A] = @@ -133,7 +133,7 @@ object EntityEncoder { implicit def stringEncoder[F[_]](implicit charset: Charset = DefaultCharset): EntityEncoder[F, String] = { val hdr = `Content-Type`(MediaType.text.plain).withCharset(charset) - simple(hdr)(s => Chunk.bytes(s.getBytes(charset.nioCharset))) + simple(hdr)(s => Chunk.array(s.getBytes(charset.nioCharset))) } implicit def charArrayEncoder[F[_]](implicit @@ -144,7 +144,7 @@ object EntityEncoder { simple(`Content-Type`(MediaType.application.`octet-stream`))(identity) implicit def byteArrayEncoder[F[_]]: EntityEncoder[F, Array[Byte]] = - chunkEncoder[F].contramap(Chunk.bytes) + chunkEncoder[F].contramap(Chunk.array[Byte]) /** Encodes an entity body. Chunking of the stream is preserved. A * `Transfer-Encoding: chunked` header is set, as we cannot know @@ -155,29 +155,24 @@ object EntityEncoder { Entity(body, None) } - // TODO parameterize chunk size // TODO if Header moves to Entity, can add a Content-Disposition with the filename - def fileEncoder[F[_]: Sync: ContextShift](blocker: Blocker): EntityEncoder[F, File] = - filePathEncoder[F](blocker).contramap(_.toPath) + implicit def fileEncoder[F[_]: Files]: EntityEncoder[F, File] = + filePathEncoder[F].contramap(_.toPath) - // TODO parameterize chunk size // TODO if Header moves to Entity, can add a Content-Disposition with the filename - def filePathEncoder[F[_]: Sync: ContextShift](blocker: Blocker): EntityEncoder[F, Path] = - encodeBy[F, Path](`Transfer-Encoding`(TransferCoding.chunked.pure[NonEmptyList])) { p => - Entity(readAll[F](p, blocker, 4096)) //2 KB :P + implicit def filePathEncoder[F[_]: Files]: EntityEncoder[F, Path] = + encodeBy[F, Path](`Transfer-Encoding`(TransferCoding.chunked)) { p => + Entity(Files[F].readAll(Fs2Path.fromNioPath(p))) } - // TODO parameterize chunk size - def inputStreamEncoder[F[_]: Sync: ContextShift, IS <: InputStream]( - blocker: Blocker): EntityEncoder[F, F[IS]] = + implicit def inputStreamEncoder[F[_]: Sync, IS <: InputStream]: EntityEncoder[F, F[IS]] = entityBodyEncoder[F].contramap { (in: F[IS]) => - readInputStream[F](in.widen[InputStream], DefaultChunkSize, blocker) + readInputStream[F](in.widen[InputStream], DefaultChunkSize) } // TODO parameterize chunk size - implicit def readerEncoder[F[_], R <: Reader](blocker: Blocker)(implicit + implicit def readerEncoder[F[_], R <: Reader](implicit F: Sync[F], - cs: ContextShift[F], charset: Charset = DefaultCharset): EntityEncoder[F, F[R]] = entityBodyEncoder[F].contramap { (fr: F[R]) => // Shared buffer @@ -185,7 +180,7 @@ object EntityEncoder { def readToBytes(r: Reader): F[Option[Chunk[Byte]]] = for { // Read into the buffer - readChars <- blocker.delay(r.read(charBuffer)) + readChars <- F.blocking(r.read(charBuffer)) } yield { // Flip to read charBuffer.flip() @@ -198,7 +193,7 @@ object EntityEncoder { // Read into a Chunk val b = new Array[Byte](bb.remaining()) bb.get(b) - Some(Chunk.bytes(b)) + Some(Chunk.array(b)) } } diff --git a/core/src/main/scala/org/http4s/FormDataDecoder.scala b/core/src/main/scala/org/http4s/FormDataDecoder.scala index 69735a3d2fc..1bc905a555a 100644 --- a/core/src/main/scala/org/http4s/FormDataDecoder.scala +++ b/core/src/main/scala/org/http4s/FormDataDecoder.scala @@ -19,7 +19,7 @@ package org.http4s import cats.Applicative import cats.data.Validated.Valid import cats.data.{Chain, ValidatedNel} -import cats.effect.Sync +import cats.effect.Concurrent import cats.syntax.all._ /** A decoder ware that uses [[QueryParamDecoder]] to decode values in [[org.http4s.UrlForm]] @@ -99,7 +99,7 @@ object FormDataDecoder { def apply(data: FormData): Result[A] = f(data) } - implicit def formEntityDecoder[F[_]: Sync, A](implicit + implicit def formEntityDecoder[F[_]: Concurrent, A](implicit fdd: FormDataDecoder[A] ): EntityDecoder[F, A] = UrlForm.entityDecoder[F].flatMapR { d => diff --git a/core/src/main/scala/org/http4s/Http.scala b/core/src/main/scala/org/http4s/Http.scala index 0db04981eb5..1aee41b6beb 100644 --- a/core/src/main/scala/org/http4s/Http.scala +++ b/core/src/main/scala/org/http4s/Http.scala @@ -17,6 +17,7 @@ package org.http4s import cats._ +import cats.syntax.all._ import cats.data.Kleisli /** Functions for creating [[Http]] kleislis. */ @@ -31,8 +32,8 @@ object Http { * @param run the function to lift * @return an [[Http]] that suspends `run`. */ - def apply[F[_], G[_]](run: Request[G] => F[Response[G]])(implicit F: Defer[F]): Http[F, G] = - Kleisli(req => F.defer(run(req))) + def apply[F[_], G[_]](run: Request[G] => F[Response[G]])(implicit F: Monad[F]): Http[F, G] = + Kleisli(req => F.unit >> run(req)) /** Lifts an effectful [[Response]] into an [[Http]] kleisli. * @@ -66,6 +67,6 @@ object Http { * being applied to `fa` */ def local[F[_], G[_]](f: Request[G] => Request[G])(fa: Http[F, G])(implicit - F: Defer[F]): Http[F, G] = - Kleisli(req => F.defer(fa.run(f(req)))) + F: Monad[F]): Http[F, G] = + Kleisli(req => F.unit >> fa.run(f(req))) } diff --git a/core/src/main/scala/org/http4s/Http4s.scala b/core/src/main/scala/org/http4s/Http4s.scala deleted file mode 100644 index 6f078e7e6d8..00000000000 --- a/core/src/main/scala/org/http4s/Http4s.scala +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 - -@deprecated("Use org.http4s.implicits._ instead", "0.20.0-M2") -trait Http4s extends Http4sInstances with Http4sFunctions with syntax.AllSyntax - -@deprecated("Use org.http4s.implicits._ instead", "0.20.0-M2") -object Http4s extends Http4s - -@deprecated("Import from or use EntityDecoder/EntityEncoder directly instead", "0.20.0-M2") -trait Http4sInstances - -@deprecated("Import from or use EntityDecoder/EntityEncoder directly instead", "0.20.0-M2") -object Http4sInstances extends Http4sInstances - -@deprecated("Use org.http4s.qvalue._ or org.http4s.Uri._ instead", "0.20.0-M2") -trait Http4sFunctions - -@deprecated("Use org.http4s.qvalue._ or org.http4s.Uri._ instead", "0.20.0-M2") -object Http4sFunctions extends Http4sFunctions diff --git a/core/src/main/scala/org/http4s/HttpApp.scala b/core/src/main/scala/org/http4s/HttpApp.scala index 2b590eeced1..283b6745b24 100644 --- a/core/src/main/scala/org/http4s/HttpApp.scala +++ b/core/src/main/scala/org/http4s/HttpApp.scala @@ -16,7 +16,7 @@ package org.http4s -import cats.{Applicative, Defer} +import cats.{Applicative, Monad} import cats.data.Kleisli /** Functions for creating [[HttpApp]] kleislis. */ @@ -30,7 +30,7 @@ object HttpApp { * @param run the function to lift * @return an [[HttpApp]] that wraps `run` */ - def apply[F[_]: Defer](run: Request[F] => F[Response[F]]): HttpApp[F] = + def apply[F[_]: Monad](run: Request[F] => F[Response[F]]): HttpApp[F] = Http(run) /** Lifts an effectful [[Response]] into an [[HttpApp]]. @@ -61,7 +61,7 @@ object HttpApp { * @return An [[HttpApp]] whose input is transformed by `f` before * being applied to `fa` */ - def local[F[_]](f: Request[F] => Request[F])(fa: HttpApp[F])(implicit F: Defer[F]): HttpApp[F] = + def local[F[_]](f: Request[F] => Request[F])(fa: HttpApp[F])(implicit F: Monad[F]): HttpApp[F] = Http.local(f)(fa) /** An app that always returns `404 Not Found`. */ diff --git a/core/src/main/scala/org/http4s/HttpDate.scala b/core/src/main/scala/org/http4s/HttpDate.scala index 1d56902d6d3..0df8e5c1a5e 100644 --- a/core/src/main/scala/org/http4s/HttpDate.scala +++ b/core/src/main/scala/org/http4s/HttpDate.scala @@ -18,11 +18,10 @@ package org.http4s import cats.Functor import cats.effect.Clock -import cats.implicits._ +import cats.syntax.all._ import cats.parse.{Parser, Rfc5234} import java.time.{DateTimeException, Instant, ZoneOffset, ZonedDateTime} import org.http4s.util.{Renderable, Writer} -import scala.concurrent.duration.SECONDS /** An HTTP-date value represents time as an instance of Coordinated Universal * Time (UTC). It expresses time at a resolution of one second. By using it @@ -80,7 +79,7 @@ object HttpDate { * problem for future generations. */ def current[F[_]: Functor: Clock]: F[HttpDate] = - Clock[F].realTime(SECONDS).map(unsafeFromEpochSecond) + Clock[F].realTime.map(v => unsafeFromEpochSecond(v.toSeconds)) /** The `HttpDate` equal to `Thu, Jan 01 1970 00:00:00 GMT` */ val Epoch: HttpDate = diff --git a/core/src/main/scala/org/http4s/HttpRoutes.scala b/core/src/main/scala/org/http4s/HttpRoutes.scala index 37f90ff52b1..4f62c21c319 100644 --- a/core/src/main/scala/org/http4s/HttpRoutes.scala +++ b/core/src/main/scala/org/http4s/HttpRoutes.scala @@ -31,7 +31,7 @@ object HttpRoutes { * @param run the function to lift * @return an [[HttpRoutes]] that wraps `run` */ - def apply[F[_]: Defer](run: Request[F] => OptionT[F, Response[F]]): HttpRoutes[F] = + def apply[F[_]: Monad](run: Request[F] => OptionT[F, Response[F]]): HttpRoutes[F] = Http(run) /** Lifts an effectful [[Response]] into an [[HttpRoutes]]. @@ -62,7 +62,7 @@ object HttpRoutes { * @return An [[HttpRoutes]] whose input is transformed by `f` before * being applied to `fa` */ - def local[F[_]: Defer](f: Request[F] => Request[F])(fa: HttpRoutes[F]): HttpRoutes[F] = + def local[F[_]: Monad](f: Request[F] => Request[F])(fa: HttpRoutes[F]): HttpRoutes[F] = Http.local[OptionT[F, *], F](f)(fa) /** Lifts a partial function into an [[HttpRoutes]]. The application of the @@ -75,8 +75,8 @@ object HttpRoutes { * @return An [[HttpRoutes]] that returns some [[Response]] in an `OptionT[F, *]` * wherever `pf` is defined, an `OptionT.none` wherever it is not */ - def of[F[_]: Defer: Applicative](pf: PartialFunction[Request[F], F[Response[F]]]): HttpRoutes[F] = - Kleisli(req => OptionT(Defer[F].defer(pf.lift(req).sequence))) + def of[F[_]: Monad](pf: PartialFunction[Request[F], F[Response[F]]]): HttpRoutes[F] = + Kleisli(req => OptionT(Applicative[F].unit >> pf.lift(req).sequence)) /** Lifts a partial function into an [[HttpRoutes]]. The application of the * partial function is not suspended in `F`, unlike [[of]]. This allows for less diff --git a/core/src/main/scala/org/http4s/LanguageTag.scala b/core/src/main/scala/org/http4s/LanguageTag.scala index 6ee347e1c0c..38469cdc6d1 100644 --- a/core/src/main/scala/org/http4s/LanguageTag.scala +++ b/core/src/main/scala/org/http4s/LanguageTag.scala @@ -28,8 +28,6 @@ final case class LanguageTag( q: QValue = QValue.One, subTags: List[String] = Nil) extends Renderable { - @deprecated("Use languageTag.withQValue", "0.16.1") - def withQuality(q: QValue): LanguageTag = LanguageTag(primaryTag, q, subTags) def withQValue(q: QValue): LanguageTag = copy(q = q) @@ -46,16 +44,6 @@ final case class LanguageTag( else if (tags2.isEmpty || tags1.head != tags2.head) false else checkLists(tags1.tail, tags2.tail) - @deprecated("Use `Accept-Language`.satisfiedBy(encoding)", "0.16.1") - def satisfies(encoding: LanguageTag): Boolean = encoding.satisfiedBy(this) - - @deprecated("Use `Accept-Language`.satisfiedBy(encoding)", "0.16.1") - def satisfiedBy(encoding: LanguageTag): Boolean = - (this.primaryTag == "*" || this.primaryTag == encoding.primaryTag) && - q.isAcceptable && encoding.q.isAcceptable && - q <= encoding.q && - checkLists(subTags, encoding.subTags) - def matches(languageTag: LanguageTag): Boolean = this.primaryTag == "*" || (this.primaryTag == languageTag.primaryTag && checkLists(subTags, languageTag.subTags)) diff --git a/core/src/main/scala/org/http4s/Media.scala b/core/src/main/scala/org/http4s/Media.scala index a09db4401a1..5521955ee2d 100644 --- a/core/src/main/scala/org/http4s/Media.scala +++ b/core/src/main/scala/org/http4s/Media.scala @@ -18,7 +18,7 @@ package org.http4s import cats.MonadThrow import fs2.{RaiseThrowable, Stream} -import fs2.text.utf8Decode +import fs2.text.utf8 import org.http4s.headers._ trait Media[F[_]] { @@ -32,7 +32,7 @@ trait Media[F[_]] { charset.getOrElse(defaultCharset) match { case Charset.`UTF-8` => // suspect this one is more efficient, though this is superstition - body.through(utf8Decode) + body.through(utf8.decode) case cs => body.through(internal.decode(cs)) } diff --git a/core/src/main/scala/org/http4s/Message.scala b/core/src/main/scala/org/http4s/Message.scala index 0e1704a4dcc..7eba553bf6f 100644 --- a/core/src/main/scala/org/http4s/Message.scala +++ b/core/src/main/scala/org/http4s/Message.scala @@ -16,16 +16,16 @@ package org.http4s -import cats.{Applicative, Functor, Monad, ~>} +import cats.{Applicative, Monad, ~>} import cats.data.NonEmptyList +import cats.effect.{Sync, SyncIO} import cats.syntax.all._ -import cats.effect.{IO, Sync} import com.comcast.ip4s.{Hostname, IpAddress, Port, SocketAddress} import fs2.{Pure, Stream} -import fs2.text.utf8Encode +import fs2.text.utf8 import java.io.File import org.http4s.headers._ -import org.http4s.syntax.{KleisliSyntax, KleisliSyntaxBinCompat0, KleisliSyntaxBinCompat1} +import org.http4s.syntax.KleisliSyntax import org.log4s.getLogger import org.typelevel.ci.CIString import org.typelevel.vault._ @@ -66,10 +66,6 @@ sealed trait Message[F[_]] extends Media[F] { self => // Body methods - @deprecated("Use withEntity", "0.19") - def withBody[T](b: T)(implicit F: Applicative[F], w: EntityEncoder[F, T]): F[Self] = - F.pure(withEntity(b)) - /** Replace the body of this message with a new body * * @param b body to attach to this method @@ -182,10 +178,6 @@ sealed trait Message[F[_]] extends Media[F] { self => // Specific header methods - @deprecated("Use withContentType(`Content-Type`(t)) instead", "0.20.0-M2") - def withType(t: MediaType)(implicit F: Functor[F]): Self = - withContentType(`Content-Type`(t)) - def withContentType(contentType: `Content-Type`): Self = putHeaders(contentType) @@ -228,7 +220,7 @@ sealed trait Message[F[_]] extends Media[F] { self => object Message { private[http4s] val logger = getLogger object Keys { - private[this] val trailerHeaders: Key[Any] = Key.newKey[IO, Any].unsafeRunSync() + private[this] val trailerHeaders: Key[Any] = Key.newKey[SyncIO, Any].unsafeRunSync() def TrailerHeaders[F[_]]: Key[F[Headers]] = trailerHeaders.asInstanceOf[Key[F[Headers]]] } } @@ -544,10 +536,10 @@ object Request { secure: Boolean) object Keys { - val PathInfoCaret: Key[Int] = Key.newKey[IO, Int].unsafeRunSync() - val PathTranslated: Key[File] = Key.newKey[IO, File].unsafeRunSync() - val ConnectionInfo: Key[Connection] = Key.newKey[IO, Connection].unsafeRunSync() - val ServerSoftware: Key[ServerSoftware] = Key.newKey[IO, ServerSoftware].unsafeRunSync() + val PathInfoCaret: Key[Int] = Key.newKey[SyncIO, Int].unsafeRunSync() + val PathTranslated: Key[File] = Key.newKey[SyncIO, File].unsafeRunSync() + val ConnectionInfo: Key[Connection] = Key.newKey[SyncIO, Connection].unsafeRunSync() + val ServerSoftware: Key[ServerSoftware] = Key.newKey[SyncIO, ServerSoftware].unsafeRunSync() } } @@ -664,7 +656,7 @@ final class Response[F[_]] private ( s"""Response(status=${status.code}, headers=${headers.redactSensitive()})""" } -object Response extends KleisliSyntax with KleisliSyntaxBinCompat0 with KleisliSyntaxBinCompat1 { +object Response extends KleisliSyntax { /** Representation of the HTTP response to send back to the client * @@ -691,7 +683,7 @@ object Response extends KleisliSyntax with KleisliSyntaxBinCompat0 with KleisliS private[this] val pureNotFound: Response[Pure] = Response( Status.NotFound, - body = Stream("Not found").through(utf8Encode), + body = Stream("Not found").through(utf8.encode), headers = Headers( `Content-Type`(MediaType.text.plain, Charset.`UTF-8`), `Content-Length`.unsafeFromLong(9L) diff --git a/core/src/main/scala/org/http4s/QueryParam.scala b/core/src/main/scala/org/http4s/QueryParam.scala index c51ab29caa5..05befe9bdb1 100644 --- a/core/src/main/scala/org/http4s/QueryParam.scala +++ b/core/src/main/scala/org/http4s/QueryParam.scala @@ -198,16 +198,6 @@ object QueryParamEncoder { fa.contramap(f) } - @deprecated("Use QueryParamEncoder[U].contramap(f)", "0.16") - def encodeBy[T, U](f: T => U)(implicit - qpe: QueryParamEncoder[U] - ): QueryParamEncoder[T] = - qpe.contramap(f) - - @deprecated("Use QueryParamEncoder[String].contramap(f)", "0.16") - def encode[T](f: T => String): QueryParamEncoder[T] = - stringQueryParamEncoder.contramap(f) - def fromShow[T](implicit sh: Show[T] ): QueryParamEncoder[T] = @@ -353,12 +343,6 @@ object QueryParamDecoder { a.orElse(b) } - @deprecated("Use QueryParamDecoder[T].map(f)", "0.16") - def decodeBy[U, T](f: T => U)(implicit - qpd: QueryParamDecoder[T] - ): QueryParamDecoder[U] = - qpd.map(f) - /** A decoder that always succeeds. */ def success[A](a: A): QueryParamDecoder[A] = fromUnsafeCast[A](_ => a)("Success") diff --git a/core/src/main/scala/org/http4s/ServerSentEvent.scala b/core/src/main/scala/org/http4s/ServerSentEvent.scala index 24bbc05d0b5..774e1a04635 100644 --- a/core/src/main/scala/org/http4s/ServerSentEvent.scala +++ b/core/src/main/scala/org/http4s/ServerSentEvent.scala @@ -18,7 +18,7 @@ package org.http4s import cats.data.Chain import fs2._ -import fs2.text.{utf8Decode, utf8Encode} +import fs2.text.utf8 import java.util.regex.Pattern import org.http4s.ServerSentEvent._ @@ -138,9 +138,9 @@ object ServerSentEvent { None, None, emptyBuffer, - stream.through(utf8Decode.andThen(text.lines))).stream + stream.through(utf8.decode.andThen(text.lines))).stream } def encoder[F[_]]: Pipe[F, ServerSentEvent, Byte] = - _.map(_.renderString).through(utf8Encode) + _.map(_.renderString).through(utf8.encode) } diff --git a/core/src/main/scala/org/http4s/StaticFile.scala b/core/src/main/scala/org/http4s/StaticFile.scala index ace8f64b1bf..f4ec7b4217d 100644 --- a/core/src/main/scala/org/http4s/StaticFile.scala +++ b/core/src/main/scala/org/http4s/StaticFile.scala @@ -16,13 +16,13 @@ package org.http4s -import cats.Semigroup +import cats.{Functor, MonadError, MonadThrow, Semigroup} import cats.data.{NonEmptyList, OptionT} -import cats.effect.{Blocker, ContextShift, IO, Sync} +import cats.effect.{Sync, SyncIO} import cats.syntax.all._ import fs2.Stream import fs2.io._ -import fs2.io.file.readRange +import fs2.io.file.{Files, Path} import java.io._ import java.net.URL import org.http4s.Status.NotModified @@ -36,15 +36,13 @@ object StaticFile { val DefaultBufferSize = 10240 - def fromString[F[_]: Sync: ContextShift]( + def fromString[F[_]: Files: MonadThrow]( url: String, - blocker: Blocker, req: Option[Request[F]] = None): OptionT[F, Response[F]] = - fromFile(new File(url), blocker, req) + fromFile(new File(url), req) - def fromResource[F[_]: Sync: ContextShift]( + def fromResource[F[_]: Sync]( name: String, - blocker: Blocker, req: Option[Request[F]] = None, preferGzipped: Boolean = false, classloader: Option[ClassLoader] = None): OptionT[F, Response[F]] = { @@ -60,14 +58,14 @@ object StaticFile { val normalizedName = name.split("/").filter(_.nonEmpty).mkString("/") def getResource(name: String) = - OptionT(Sync[F].delay(Option(loader.getResource(name)))) + OptionT(Sync[F].blocking(Option(loader.getResource(name)))) val gzUrl: OptionT[F, URL] = if (tryGzipped) getResource(normalizedName + ".gz") else OptionT.none gzUrl .flatMap { url => - fromURL(url, blocker, req).map { + fromURL(url, req).map { _.removeHeader[`Content-Type`] .putHeaders( `Content-Encoding`(ContentCoding.gzip), @@ -76,17 +74,16 @@ object StaticFile { } } .orElse(getResource(normalizedName) - .flatMap(fromURL(_, blocker, req))) + .flatMap(fromURL(_, req))) } - def fromURL[F[_]](url: URL, blocker: Blocker, req: Option[Request[F]] = None)(implicit - F: Sync[F], - cs: ContextShift[F]): OptionT[F, Response[F]] = { + def fromURL[F[_]](url: URL, req: Option[Request[F]] = None)(implicit + F: Sync[F]): OptionT[F, Response[F]] = { val fileUrl = url.getFile() val file = new File(fileUrl) OptionT.apply(F.defer { - if (url.getProtocol === "file" && file.isDirectory) - F.pure(None) + if (url.getProtocol === "file" && file.isDirectory()) + F.pure(none[Response[F]]) else { val urlConn = url.openConnection val lastmod = HttpDate.fromEpochSecond(urlConn.getLastModified / 1000).toOption @@ -103,8 +100,7 @@ object StaticFile { else `Transfer-Encoding`(TransferCoding.chunked.pure[NonEmptyList]) ) - blocker - .delay(urlConn.getInputStream) + F.blocking(urlConn.getInputStream) .redeem( recover = { case _: FileNotFoundException => None @@ -114,89 +110,93 @@ object StaticFile { Some( Response( headers = headers, - body = readInputStream[F](F.pure(inputStream), DefaultBufferSize, blocker) + body = readInputStream[F](F.pure(inputStream), DefaultBufferSize) )) } ) } else - blocker - .delay(urlConn.getInputStream.close()) + F.blocking(urlConn.getInputStream.close()) .handleError(_ => ()) .as(Some(Response(NotModified))) } }) } - def calcETag[F[_]: Sync]: File => F[String] = + def calcETag[F[_]: Files: Functor]: File => F[String] = f => - Sync[F].delay( - if (f.isFile) s"${f.lastModified().toHexString}-${f.length().toHexString}" else "") + Files[F] + .isRegularFile(Path.fromNioPath(f.toPath())) + .map(isFile => + if (isFile) s"${f.lastModified().toHexString}-${f.length().toHexString}" else "") - def fromFile[F[_]: Sync: ContextShift]( + def fromFile[F[_]: Files: MonadThrow]( f: File, - blocker: Blocker, req: Option[Request[F]] = None): OptionT[F, Response[F]] = - fromFile(f, DefaultBufferSize, blocker, req, calcETag[F]) + fromFile(f, DefaultBufferSize, req, calcETag[F]) - def fromFile[F[_]: Sync: ContextShift]( + def fromFile[F[_]: Files: MonadThrow]( f: File, - blocker: Blocker, req: Option[Request[F]], etagCalculator: File => F[String]): OptionT[F, Response[F]] = - fromFile(f, DefaultBufferSize, blocker, req, etagCalculator) + fromFile(f, DefaultBufferSize, req, etagCalculator) - def fromFile[F[_]: Sync: ContextShift]( + def fromFile[F[_]: Files: MonadThrow]( f: File, buffsize: Int, - blocker: Blocker, req: Option[Request[F]], etagCalculator: File => F[String]): OptionT[F, Response[F]] = - fromFile(f, 0, f.length(), buffsize, blocker, req, etagCalculator) + fromFile(f, 0, f.length(), buffsize, req, etagCalculator) - def fromFile[F[_]]( + def fromFile[F[_]: Files]( f: File, start: Long, end: Long, buffsize: Int, - blocker: Blocker, req: Option[Request[F]], - etagCalculator: File => F[String])(implicit - F: Sync[F], - cs: ContextShift[F]): OptionT[F, Response[F]] = + etagCalculator: File => F[String] + )(implicit + F: MonadError[F, Throwable] + ): OptionT[F, Response[F]] = OptionT(for { etagCalc <- etagCalculator(f).map(et => ETag(et)) - res <- F.delay { - if (f.isFile) { - require( - start >= 0 && end >= start && buffsize > 0, - s"start: $start, end: $end, buffsize: $buffsize") - - val lastModified = HttpDate.fromEpochSecond(f.lastModified / 1000).toOption - - notModified(req, etagCalc, lastModified).orElse { - val (body, contentLength) = - if (f.length() < end) (Stream.empty.covary[F], 0L) - else (fileToBody[F](f, start, end, blocker), end - start) - - val hs = - Headers( - lastModified.map(`Last-Modified`(_)), - `Content-Length`.fromLong(contentLength).toOption, - nameToContentType(f.getName), - etagCalc - ) - - val r = Response( - headers = hs, - body = body, - attributes = Vault.empty.insert(staticFileKey, f) - ) - - logger.trace(s"Static file generated response: $r") - Some(r) + res <- Files[F].isRegularFile(Path.fromNioPath(f.toPath)).flatMap[Option[Response[F]]] { + isFile => + if (isFile) { + if (start >= 0 && end >= start && buffsize > 0) { + val lastModified = HttpDate.fromEpochSecond(f.lastModified / 1000).toOption + + F.pure(notModified(req, etagCalc, lastModified).orElse { + val (body, contentLength) = + if (f.length() < end) (Stream.empty.covary[F], 0L) + else (fileToBody[F](f, start, end), end - start) + + val contentType = nameToContentType(f.getName) + val hs = + Headers( + lastModified.map(`Last-Modified`(_)), + `Content-Length`.fromLong(contentLength).toOption, + contentType, + etagCalc + ) + + val r = Response( + headers = hs, + body = body, + attributes = Vault.empty.insert(staticFileKey, f) + ) + + logger.trace(s"Static file generated response: $r") + r.some + }) + } else { + F.raiseError[Option[Response[F]]](new IllegalArgumentException( + s"requirement failed: start: $start, end: $end, buffsize: $buffsize")) + } + + } else { + F.pure(none[Response[F]]) } - } else - None + } } yield res) @@ -232,13 +232,8 @@ object StaticFile { s"Matches `If-Modified-Since`: $notModified. Request age: ${h.date}, Modified: $lm") } yield notModified - private def fileToBody[F[_]: Sync: ContextShift]( - f: File, - start: Long, - end: Long, - blocker: Blocker - ): EntityBody[F] = - readRange[F](f.toPath, blocker, DefaultBufferSize, start, end) + private def fileToBody[F[_]: Files](f: File, start: Long, end: Long): EntityBody[F] = + Files[F].readRange(Path.fromNioPath(f.toPath), DefaultBufferSize, start, end) private def nameToContentType(name: String): Option[`Content-Type`] = name.lastIndexOf('.') match { @@ -246,5 +241,6 @@ object StaticFile { case i => MediaType.forExtension(name.substring(i + 1)).map(`Content-Type`(_)) } - private[http4s] val staticFileKey = Key.newKey[IO, File].unsafeRunSync() + private[http4s] val staticFileKey = + Key.newKey[SyncIO, File].unsafeRunSync() } diff --git a/core/src/main/scala/org/http4s/Status.scala b/core/src/main/scala/org/http4s/Status.scala index f751c4dab50..588d5236283 100644 --- a/core/src/main/scala/org/http4s/Status.scala +++ b/core/src/main/scala/org/http4s/Status.scala @@ -100,19 +100,6 @@ object Status { case object ClientError extends ResponseClass { val isSuccess = false } case object ServerError extends ResponseClass { val isSuccess = false } - object ResponseClass { - @deprecated("Moved to org.http4s.Status.Informational", "0.16") - val Informational = Status.Informational - @deprecated("Moved to org.http4s.Status.Successful", "0.16") - val Successful = Status.Successful - @deprecated("Moved to org.http4s.Status.Redirection", "0.16") - val Redirection = Status.Informational - @deprecated("Moved to org.http4s.Status.ClientError", "0.16") - val ClientError = Status.Informational - @deprecated("Moved to org.http4s.Status.ServerError", "0.16") - val ServerError = Status.Informational - } - private[http4s] val MinCode = 100 private[http4s] val MaxCode = 599 diff --git a/core/src/main/scala/org/http4s/Uri.scala b/core/src/main/scala/org/http4s/Uri.scala index 9f60ca57998..2f697b7bab3 100644 --- a/core/src/main/scala/org/http4s/Uri.scala +++ b/core/src/main/scala/org/http4s/Uri.scala @@ -16,7 +16,7 @@ import cats.kernel.Semigroup import cats.parse.{Parser0, Parser => P} import cats.syntax.all._ import com.comcast.ip4s -import java.net.{Inet4Address, Inet6Address} +import java.net.{Inet4Address, Inet6Address, InetAddress} import java.nio.{ByteBuffer, CharBuffer} import java.nio.charset.{Charset => JCharset} import java.nio.charset.StandardCharsets @@ -668,7 +668,7 @@ object Uri extends UriPlatform { def toByteArray: Array[Byte] = address.toBytes - def toInet6Address: Inet6Address = + def toInetAddress: InetAddress = address.toInetAddress def value: String = diff --git a/core/src/main/scala/org/http4s/UrlForm.scala b/core/src/main/scala/org/http4s/UrlForm.scala index 603fc7ff5cb..94df11322e2 100644 --- a/core/src/main/scala/org/http4s/UrlForm.scala +++ b/core/src/main/scala/org/http4s/UrlForm.scala @@ -18,7 +18,7 @@ package org.http4s import cats.{Eq, Monoid} import cats.data.Chain -import cats.effect.Sync +import cats.effect.Concurrent import cats.syntax.all._ import org.http4s.headers._ import org.http4s.internal.CollectionCompat @@ -105,7 +105,7 @@ object UrlForm { .withContentType(`Content-Type`(MediaType.application.`x-www-form-urlencoded`, charset)) implicit def entityDecoder[F[_]](implicit - F: Sync[F], + F: Concurrent[F], defaultCharset: Charset = DefaultCharset): EntityDecoder[F, UrlForm] = EntityDecoder.decodeBy(MediaType.application.`x-www-form-urlencoded`) { m => DecodeResult( diff --git a/core/src/main/scala/org/http4s/headers/Accept-Charset.scala b/core/src/main/scala/org/http4s/headers/Accept-Charset.scala index c14a40def30..a3432e52206 100644 --- a/core/src/main/scala/org/http4s/headers/Accept-Charset.scala +++ b/core/src/main/scala/org/http4s/headers/Accept-Charset.scala @@ -82,9 +82,6 @@ final case class `Accept-Charset`(values: NonEmptyList[CharsetRange]) { specific.orElse(splatted).getOrElse(QValue.Zero) } - @deprecated("Use satisfiedBy(charset)", "0.16.1") - def isSatisfiedBy(charset: Charset): Boolean = satisfiedBy(charset) - def satisfiedBy(charset: Charset): Boolean = qValue(charset) > QValue.Zero def map(f: CharsetRange => CharsetRange): `Accept-Charset` = `Accept-Charset`(values.map(f)) diff --git a/core/src/main/scala/org/http4s/headers/Accept-Encoding.scala b/core/src/main/scala/org/http4s/headers/Accept-Encoding.scala index 79add1a4811..84a12d89890 100644 --- a/core/src/main/scala/org/http4s/headers/Accept-Encoding.scala +++ b/core/src/main/scala/org/http4s/headers/Accept-Encoding.scala @@ -48,10 +48,6 @@ object `Accept-Encoding` { } final case class `Accept-Encoding`(values: NonEmptyList[ContentCoding]) { - @deprecated("Has confusing semantics in the presence of splat. Do not use.", "0.16.1") - def preferred: ContentCoding = - values.tail.fold(values.head)((a, b) => if (a.qValue >= b.qValue) a else b) - def qValue(coding: ContentCoding): QValue = { def specific = values.toList.collectFirst { diff --git a/core/src/main/scala/org/http4s/headers/Accept-Language.scala b/core/src/main/scala/org/http4s/headers/Accept-Language.scala index 1f4ab6e92b1..91fc8914670 100644 --- a/core/src/main/scala/org/http4s/headers/Accept-Language.scala +++ b/core/src/main/scala/org/http4s/headers/Accept-Language.scala @@ -59,10 +59,6 @@ object `Accept-Language` { * [[https://tools.ietf.org/html/rfc7231#section-5.3.5 RFC-7231 Section 5.3.5]] */ final case class `Accept-Language`(values: NonEmptyList[LanguageTag]) { - - @deprecated("Has confusing semantics in the presence of splat. Do not use.", "0.16.1") - def preferred: LanguageTag = values.tail.fold(values.head)((a, b) => if (a.q >= b.q) a else b) - def qValue(languageTag: LanguageTag): QValue = values.toList .collect { diff --git a/core/src/main/scala/org/http4s/implicits.scala b/core/src/main/scala/org/http4s/implicits.scala index 91b127da98f..290170c3339 100644 --- a/core/src/main/scala/org/http4s/implicits.scala +++ b/core/src/main/scala/org/http4s/implicits.scala @@ -16,4 +16,4 @@ package org.http4s -object implicits extends syntax.AllSyntaxBinCompat +object implicits extends syntax.AllSyntax diff --git a/core/src/main/scala/org/http4s/internal/BackendBuilder.scala b/core/src/main/scala/org/http4s/internal/BackendBuilder.scala index caac5b37bb7..5697408b83e 100644 --- a/core/src/main/scala/org/http4s/internal/BackendBuilder.scala +++ b/core/src/main/scala/org/http4s/internal/BackendBuilder.scala @@ -16,11 +16,11 @@ package org.http4s.internal -import cats.effect.{BracketThrow, Resource} +import cats.effect.{MonadCancelThrow, Resource} import fs2.Stream private[http4s] trait BackendBuilder[F[_], A] { - protected implicit def F: BracketThrow[F] + protected implicit def F: MonadCancelThrow[F] /** Returns the backend as a resource. Resource acquire waits * until the backend is ready to process requests. diff --git a/core/src/main/scala/org/http4s/internal/ChunkWriter.scala b/core/src/main/scala/org/http4s/internal/ChunkWriter.scala index 35d13806bd8..e391e268ea2 100644 --- a/core/src/main/scala/org/http4s/internal/ChunkWriter.scala +++ b/core/src/main/scala/org/http4s/internal/ChunkWriter.scala @@ -29,10 +29,10 @@ private[http4s] class ChunkWriter( ) extends Writer { private[this] val chunks = Buffer[Chunk[Byte]]() - def toChunk: Chunk[Byte] = Chunk.concatBytes(chunks) + def toChunk: Chunk[Byte] = Chunk.concat(chunks) override def append(s: String): this.type = { - chunks += Chunk.bytes(s.getBytes(charset)) + chunks += Chunk.array(s.getBytes(charset)) this } } diff --git a/core/src/main/scala/org/http4s/internal/Logger.scala b/core/src/main/scala/org/http4s/internal/Logger.scala index 19847866251..7b2a8f75a1d 100644 --- a/core/src/main/scala/org/http4s/internal/Logger.scala +++ b/core/src/main/scala/org/http4s/internal/Logger.scala @@ -17,7 +17,7 @@ package org.http4s.internal import cats.Monad -import cats.effect.Sync +import cats.effect.Concurrent import cats.syntax.all._ import fs2.Stream import org.http4s.{Charset, Headers, MediaType, Message, Request, Response} @@ -32,7 +32,8 @@ object Logger { message.headers.redactSensitive(redactHeadersWhen).headers.mkString("Headers(", ", ", ")") else "" - def defaultLogBody[F[_]: Sync, A <: Message[F]](message: A)(logBody: Boolean): Option[F[String]] = + def defaultLogBody[F[_]: Concurrent, A <: Message[F]](message: A)( + logBody: Boolean): Option[F[String]] = if (logBody) { val isBinary = message.contentType.exists(_.mediaType.binary) val isJson = message.contentType.exists(mT => @@ -49,8 +50,7 @@ object Logger { logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains)( - log: String => F[Unit])(implicit F: Sync[F]): F[Unit] = { - + log: String => F[Unit])(implicit F: Concurrent[F]): F[Unit] = { val logBodyText = (_: Stream[F, Byte]) => defaultLogBody[F, A](message)(logBody) logMessageWithBodyText[F, A](message)(logHeaders, logBodyText, redactHeadersWhen)(log) diff --git a/core/src/main/scala/org/http4s/internal/package.scala b/core/src/main/scala/org/http4s/internal/package.scala index 98a1a5c5e9a..2519f744ada 100644 --- a/core/src/main/scala/org/http4s/internal/package.scala +++ b/core/src/main/scala/org/http4s/internal/package.scala @@ -23,39 +23,27 @@ import java.util.concurrent.{ CompletionStage } -import cats.{Comonad, Eval, Order} -import cats.data.NonEmptyChain -import cats.effect.implicits._ -import cats.effect.{Async, Concurrent, ConcurrentEffect, ContextShift, Effect, IO} +import cats._ +import cats.data._ +import cats.effect.std.Dispatcher +import cats.effect.{Async, Sync} import cats.syntax.all._ import fs2.{Chunk, Pipe, Pull, RaiseThrowable, Stream} import java.nio.{ByteBuffer, CharBuffer} -import org.log4s.Logger - -import scala.concurrent.ExecutionContext -import scala.util.control.NoStackTrace import java.nio.charset.MalformedInputException import java.nio.charset.UnmappableCharacterException +import org.log4s.Logger +import scala.util.control.NoStackTrace package object internal { - // Like fs2.async.unsafeRunAsync before 1.0. Convenient for when we - // have an ExecutionContext but not a Timer. - private[http4s] def unsafeRunAsync[F[_], A](fa: F[A])( - f: Either[Throwable, A] => IO[Unit])(implicit F: Effect[F], ec: ExecutionContext): Unit = - F.runAsync(Async.shift(ec) *> fa)(f).unsafeRunSync() - private[http4s] def loggingAsyncCallback[A](logger: Logger)( - attempt: Either[Throwable, A]): IO[Unit] = + private[http4s] def loggingAsyncCallback[F[_], A](logger: Logger)(attempt: Either[Throwable, A])( + implicit F: Sync[F]): F[Unit] = attempt match { - case Left(e) => IO(logger.error(e)("Error in asynchronous callback")) - case Right(_) => IO.unit + case Left(e) => F.delay(logger.error(e)("Error in asynchronous callback")) + case Right(_) => F.unit } - // Inspired by https://github.com/functional-streams-for-scala/fs2/blob/14d20f6f259d04df410dc3b1046bc843a19d73e5/io/src/main/scala/fs2/io/io.scala#L140-L141 - private[http4s] def invokeCallback[F[_]](logger: Logger)(f: => Unit)(implicit - F: ConcurrentEffect[F]): Unit = - F.runAsync(F.start(F.delay(f)).flatMap(_.join))(loggingAsyncCallback(logger)).unsafeRunSync() - /** Hex encoding digits. Adapted from apache commons Hex.encodeHex */ private val Digits: Array[Char] = Array('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') @@ -135,10 +123,9 @@ package object internal { private[http4s] def fromCompletionStage[F[_], CF[x] <: CompletionStage[x], A]( fcs: F[CF[A]])(implicit // Concurrent is intentional, see https://github.com/http4s/http4s/pull/3255#discussion_r395719880 - F: Concurrent[F], - CS: ContextShift[F]): F[A] = + F: Async[F]): F[A] = fcs.flatMap { cs => - F.async[A] { cb => + F.async_ { cb => cs.handle[Unit] { (result, err) => err match { case null => cb(Right(result)) @@ -148,17 +135,18 @@ package object internal { } } () - }.guarantee(CS.shift) + } } private[http4s] def unsafeToCompletionStage[F[_], A]( - fa: F[A] - )(implicit F: Effect[F]): CompletionStage[A] = { + fa: F[A], + dispatcher: Dispatcher[F] + )(implicit F: Sync[F]): CompletionStage[A] = { val cf = new CompletableFuture[A]() - F.runAsync(fa) { - case Right(a) => IO { cf.complete(a); () } - case Left(e) => IO { cf.completeExceptionally(e); () } - }.unsafeRunSync() + dispatcher.unsafeToFuture(fa.attemptTap { + case Right(a) => F.delay { cf.complete(a); () } + case Left(e) => F.delay { cf.completeExceptionally(e); () } + }) cf } diff --git a/core/src/main/scala/org/http4s/multipart/Boundary.scala b/core/src/main/scala/org/http4s/multipart/Boundary.scala index db4a5961d47..44423801012 100644 --- a/core/src/main/scala/org/http4s/multipart/Boundary.scala +++ b/core/src/main/scala/org/http4s/multipart/Boundary.scala @@ -24,7 +24,7 @@ import scala.util.Random final case class Boundary(value: String) extends AnyVal { def toChunk: Chunk[Byte] = - Chunk.bytes(value.getBytes(StandardCharsets.UTF_8)) + Chunk.array(value.getBytes(StandardCharsets.UTF_8)) } object Boundary { diff --git a/core/src/main/scala/org/http4s/multipart/MultipartDecoder.scala b/core/src/main/scala/org/http4s/multipart/MultipartDecoder.scala index eb649ff9375..3d1c13c3fba 100644 --- a/core/src/main/scala/org/http4s/multipart/MultipartDecoder.scala +++ b/core/src/main/scala/org/http4s/multipart/MultipartDecoder.scala @@ -17,30 +17,68 @@ package org.http4s package multipart -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.Concurrent +import cats.effect.Resource +import cats.effect.std.Supervisor import cats.syntax.all._ +import fs2.io.file.Files +import fs2.Pipe private[http4s] object MultipartDecoder { - def decoder[F[_]: Sync]: EntityDecoder[F, Multipart[F]] = - EntityDecoder.decodeBy(MediaRange.`multipart/*`) { msg => - msg.contentType.flatMap(_.mediaType.extensions.get("boundary")) match { - case Some(boundary) => - DecodeResult { - msg.body - .through(MultipartParser.parseToPartsStream[F](Boundary(boundary))) - .compile - .toVector - .map[Either[DecodeFailure, Multipart[F]]](parts => - Right(Multipart(parts, Boundary(boundary)))) - .handleError { - case e: InvalidMessageBodyFailure => Left(e) - case e => Left(InvalidMessageBodyFailure("Invalid multipart body", Some(e))) - } - } - case None => - DecodeResult.failureT( - InvalidMessageBodyFailure("Missing boundary extension to Content-Type")) - } + def decoder[F[_]: Concurrent]: EntityDecoder[F, Multipart[F]] = + makeDecoder(MultipartParser.parseToPartsStream[F](_)) + + /** Multipart decoder that streams all parts past a threshold + * (anything above `maxSizeBeforeWrite`) into a temporary file. + * The decoder is only valid inside the `Resource` scope; once + * the `Resource` is released, all the created files are deleted. + * + * Note that no files are deleted until the `Resource` is released. + * Thus, sharing and reusing the resulting `EntityDecoder` is not + * recommended, and can lead to disk space leaks. + * + * The intended way to use this is as follows: + * + * {{{ + * mixedMultipartResource[F]() + * .flatTap(request.decodeWith(_, strict = true)) + * .use { multipart => + * // Use the decoded entity + * } + * }}} + * + * @param headerLimit the max size for the headers, in bytes. This is required as + * headers are strictly evaluated and parsed. + * @param maxSizeBeforeWrite the maximum size of a particular part before writing to a file is triggered + * @param maxParts the maximum number of parts this decoder accepts. NOTE: this also may mean that a body that doesn't + * conform perfectly to the spec (i.e isn't terminated properly) but has a lot of parts might + * be parsed correctly, despite the total body being malformed due to not conforming to the multipart + * spec. You can control this by `failOnLimit`, by setting it to true if you want to raise + * an error if sending too many parts to a particular endpoint + * @param failOnLimit Fail if `maxParts` is exceeded _during_ multipart parsing. + * @param chunkSize the size of chunks created when reading data from temporary files. + * @return A multipart/form-data encoded vector of parts with some part bodies held in + * temporary files. + */ + def mixedMultipartResource[F[_]: Concurrent: Files]( + headerLimit: Int = 1024, + maxSizeBeforeWrite: Int = 52428800, + maxParts: Int = 50, + failOnLimit: Boolean = false, + chunkSize: Int = 8192 + ): Resource[F, EntityDecoder[F, Multipart[F]]] = + Supervisor[F].map { supervisor => + makeDecoder( + MultipartParser.parseToPartsSupervisedFile[F]( + supervisor, + _, + headerLimit, + maxSizeBeforeWrite, + maxParts, + failOnLimit, + chunkSize + ) + ) } /** Multipart decoder that streams all parts past a threshold @@ -69,25 +107,31 @@ private[http4s] object MultipartDecoder { * @return A multipart/form-data encoded vector of parts with some part bodies held in * temporary files. */ - def mixedMultipart[F[_]: Sync: ContextShift]( - blocker: Blocker, + @deprecated("Use mixedMultipartResource", "0.23") + def mixedMultipart[F[_]: Concurrent: Files]( headerLimit: Int = 1024, maxSizeBeforeWrite: Int = 52428800, maxParts: Int = 50, failOnLimit: Boolean = false): EntityDecoder[F, Multipart[F]] = + makeDecoder( + MultipartParser.parseToPartsStreamedFile[F]( + _, + headerLimit, + maxSizeBeforeWrite, + maxParts, + failOnLimit + ) + ) + + private def makeDecoder[F[_]: Concurrent]( + impl: Boundary => Pipe[F, Byte, Part[F]] + ): EntityDecoder[F, Multipart[F]] = EntityDecoder.decodeBy(MediaRange.`multipart/*`) { msg => msg.contentType.flatMap(_.mediaType.extensions.get("boundary")) match { case Some(boundary) => DecodeResult { msg.body - .through( - MultipartParser.parseToPartsStreamedFile[F]( - Boundary(boundary), - blocker, - headerLimit, - maxSizeBeforeWrite, - maxParts, - failOnLimit)) + .through(impl(Boundary(boundary))) .compile .toVector .map[Either[DecodeFailure, Multipart[F]]](parts => diff --git a/core/src/main/scala/org/http4s/multipart/MultipartEncoder.scala b/core/src/main/scala/org/http4s/multipart/MultipartEncoder.scala index d9b17a92ead..03bac18d2e3 100644 --- a/core/src/main/scala/org/http4s/multipart/MultipartEncoder.scala +++ b/core/src/main/scala/org/http4s/multipart/MultipartEncoder.scala @@ -68,7 +68,7 @@ private[http4s] class MultipartEncoder[F[_]] extends EntityEncoder[F, Multipart[ def renderPart(prelude: Chunk[Byte])(part: Part[F]): Stream[F, Byte] = Stream.chunk(prelude) ++ Stream.chunk(renderHeaders(part.headers)) ++ - Stream.chunk(Chunk.bytes(Boundary.CRLF.getBytes(StandardCharsets.UTF_8))) ++ + Stream.chunk(Chunk.array(Boundary.CRLF.getBytes(StandardCharsets.UTF_8))) ++ part.body def renderParts(boundary: Boundary)(parts: Vector[Part[F]]): Stream[F, Byte] = @@ -79,7 +79,7 @@ private[http4s] class MultipartEncoder[F[_]] extends EntityEncoder[F, Multipart[ .foldLeft(renderPart(start(boundary))(parts.head)) { (acc, part) => acc ++ renderPart( - Chunk.bytes(encapsulationWithoutBody(boundary).getBytes(StandardCharsets.UTF_8)))( + Chunk.array(encapsulationWithoutBody(boundary).getBytes(StandardCharsets.UTF_8)))( part) } ++ Stream.chunk(end(boundary)) } diff --git a/core/src/main/scala/org/http4s/multipart/MultipartParser.scala b/core/src/main/scala/org/http4s/multipart/MultipartParser.scala index 52a7c7c97a5..4fc554b7bfb 100644 --- a/core/src/main/scala/org/http4s/multipart/MultipartParser.scala +++ b/core/src/main/scala/org/http4s/multipart/MultipartParser.scala @@ -17,17 +17,18 @@ package org.http4s package multipart -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.Concurrent import cats.syntax.all._ import fs2.{Chunk, Pipe, Pull, Pure, Stream} -import fs2.io.file.{readAll, writeAll} -import java.nio.file.{Files, Path, StandardOpenOption} +import fs2.io.file.{Files, Flags, Path} import org.typelevel.ci.CIString +import fs2.RaiseThrowable +import org.http4s.internal.bug +import cats.effect.std.Supervisor +import cats.effect.Resource /** A low-level multipart-parsing pipe. Most end users will prefer EntityDecoder[Multipart]. */ object MultipartParser { - private[this] val logger = org.log4s.getLogger - private[this] val CRLFBytesN = Array[Byte]('\r', '\n') private[this] val DoubleCRLFBytesN = Array[Byte]('\r', '\n', '\r', '\n') private[this] val DashDashBytesN = Array[Byte]('-', '-') @@ -35,28 +36,52 @@ object MultipartParser { boundary.value.getBytes("UTF-8") val StartLineBytesN: Boundary => Array[Byte] = BoundaryBytesN.andThen(DashDashBytesN ++ _) + /** `delimiter` in RFC 2046 */ private[this] val ExpectedBytesN: Boundary => Array[Byte] = BoundaryBytesN.andThen(CRLFBytesN ++ DashDashBytesN ++ _) private[this] val dashByte: Byte = '-'.toByte private[this] val streamEmpty = Stream.empty - private[this] val PullUnit = Pull.pure[Pure, Unit](()) private type SplitStream[F[_]] = Pull[F, Nothing, (Stream[F, Byte], Stream[F, Byte])] - private type SplitFileStream[F[_]] = - Pull[F, Nothing, (Stream[F, Byte], Stream[F, Byte], Option[Path])] - def parseStreamed[F[_]: Sync]( + private[this] sealed trait Event + private[this] final case class PartStart(value: Headers) extends Event + private[this] final case class PartChunk(value: Chunk[Byte]) extends Event + private[this] case object PartEnd extends Event + + def parseStreamed[F[_]: Concurrent]( boundary: Boundary, limit: Int = 1024): Pipe[F, Byte, Multipart[F]] = { st => - ignorePrelude[F](boundary, st, limit) - .fold(Vector.empty[Part[F]])(_ :+ _) + st.through( + parseToPartsStream(boundary, limit) + ).fold(Vector.empty[Part[F]])(_ :+ _) .map(Multipart(_, boundary)) } - def parseToPartsStream[F[_]: Sync]( - boundary: Boundary, - limit: Int = 1024): Pipe[F, Byte, Part[F]] = { st => - ignorePrelude[F](boundary, st, limit) + def parseToPartsStream[F[_]](boundary: Boundary, limit: Int = 1024)(implicit + F: Concurrent[F]): Pipe[F, Byte, Part[F]] = { st => + st.through( + parseEvents[F](boundary, limit) + ) + // The left half is the part under construction, the right half is a part to be emitted. + .evalMapAccumulate[F, Option[Part[F]], Option[Part[F]]](None) { (acc, item) => + (acc, item) match { + case (None, PartStart(headers)) => + F.pure((Some(Part(headers, Stream.empty)), None)) + // Shouldn't happen if the `parseToEventsStream` contract holds. + case (None, (_: PartChunk | PartEnd)) => + F.raiseError(bug("Missing PartStart")) + case (Some(acc0), PartChunk(chunk)) => + F.pure((Some(acc0.copy(body = acc0.body ++ Stream.chunk(chunk))), None)) + case (Some(_), PartEnd) => + // Part done - emit it and start over. + F.pure((None, acc)) + // Shouldn't happen if the `parseToEventsStream` contract holds. + case (Some(_), _: PartStart) => + F.raiseError(bug("Missing PartEnd")) + } + } + .mapFilter(_._2) } private def splitAndIgnorePrev[F[_]]( @@ -185,89 +210,6 @@ object MultipartParser { splitPartialMatch(middleChunked, currState, i, acc, carry, c) } - /** The first part of our streaming stages: - * - * Ignore the prelude and remove the first boundary. Only traverses until the first - * part - */ - private[this] def ignorePrelude[F[_]: Sync]( - b: Boundary, - stream: Stream[F, Byte], - limit: Int): Stream[F, Part[F]] = { - val values = StartLineBytesN(b) - - def go(s: Stream[F, Byte], state: Int, strim: Stream[F, Byte]): Pull[F, Part[F], Unit] = - if (state == values.length) - pullParts[F](b, strim ++ s, limit) - else - s.pull.uncons.flatMap { - case Some((chnk, rest)) => - val (ix, strim) = splitAndIgnorePrev(values, state, chnk) - go(rest, ix, strim) - case None => - Pull.raiseError[F](MalformedMessageBodyFailure("Malformed Malformed match")) - } - - stream.pull.uncons.flatMap { - case Some((chnk, strim)) => - val (ix, rest) = splitAndIgnorePrev(values, 0, chnk) - go(strim, ix, rest) - case None => - Pull.raiseError[F](MalformedMessageBodyFailure("Cannot parse empty stream")) - }.stream - } - - /** @param boundary - * @param s - * @param limit - * @tparam F - * @return - */ - private def pullParts[F[_]: Sync]( - boundary: Boundary, - s: Stream[F, Byte], - limit: Int - ): Pull[F, Part[F], Unit] = { - val values = DoubleCRLFBytesN - val expectedBytes = ExpectedBytesN(boundary) - - splitOrFinish[F](values, s, limit).flatMap { case (l, r) => - //We can abuse reference equality here for efficiency - //Since `splitOrFinish` returns `empty` on a capped stream - //However, we must have at least one part, so `splitOrFinish` on this function - //Indicates an error - if (r == streamEmpty) - Pull.raiseError[F](MalformedMessageBodyFailure("Cannot parse empty stream")) - else - tailrecParts[F](boundary, l, r, expectedBytes, limit) - } - } - - private def tailrecParts[F[_]: Sync]( - b: Boundary, - headerStream: Stream[F, Byte], - rest: Stream[F, Byte], - expectedBytes: Array[Byte], - limit: Int): Pull[F, Part[F], Unit] = - Pull - .eval(parseHeaders(headerStream)) - .flatMap { hdrs => - splitHalf(expectedBytes, rest).flatMap { case (l, r) => - //We hit a boundary, but the rest of the stream is empty - //and thus it's not a properly capped multipart body - if (r == streamEmpty) - Pull.raiseError[F](MalformedMessageBodyFailure("Part not terminated properly")) - else - Pull.output1(Part[F](hdrs, l)) >> splitOrFinish(DoubleCRLFBytesN, r, limit).flatMap { - case (hdrStream, remaining) => - if (hdrStream == streamEmpty) //Empty returned if it worked fine - Pull.done - else - tailrecParts[F](b, hdrStream, remaining, expectedBytes, limit) - } - } - } - /** Split a stream in half based on `values`, * but check if it is either double dash terminated (end of multipart). * SplitOrFinish also tracks a header limit size @@ -275,7 +217,7 @@ object MultipartParser { * If it is, drain the epilogue and return the empty stream. if it is not, * split on the `values` and raise an error if we lack a match */ - private def splitOrFinish[F[_]: Sync]( + private def splitOrFinish[F[_]: Concurrent]( values: Array[Byte], stream: Stream[F, Byte], limit: Int): SplitStream[F] = { @@ -283,18 +225,6 @@ object MultipartParser { //whether it's the boundary plus an extra "--", indicating it's //the last boundary def checkIfLast(c: Chunk[Byte], rest: Stream[F, Byte]): SplitStream[F] = { - //Elide empty chunks until nonemptychunk is found - def elideEmptyChunks(str: Stream[F, Byte]): Pull[F, Nothing, (Chunk[Byte], Stream[F, Byte])] = - str.pull.uncons.flatMap { - case Some((chnk, r)) => - if (chnk.size <= 0) - elideEmptyChunks(r) - else - Pull.pure((chnk, r)) - case None => - Pull.raiseError[F](MalformedMessageBodyFailure("Malformed Multipart ending")) - } - //precond: both c1 and c2 are nonempty chunks def checkTwoNonEmpty( c1: Chunk[Byte], @@ -309,27 +239,16 @@ object MultipartParser { splitOnChunkLimited[F]( values, 0, - Chunk.bytes(c1.toArray[Byte] ++ c2.toArray[Byte]), + Chunk.array(c1.toArray[Byte] ++ c2.toArray[Byte]), Stream.empty, Stream.empty) go(remaining, ix, l, r, add) } - if (c.size <= 0) - rest.pull.uncons.flatMap { - case Some((chnk, r)) => - checkIfLast(chnk, r) - case None => - Pull.raiseError[F](MalformedMessageBodyFailure("Malformed Multipart ending")) - } - else if (c.size == 1) + if (c.size == 1) rest.pull.uncons.flatMap { case Some((chnk, remaining)) => - if (chnk.size <= 0) - elideEmptyChunks(remaining).flatMap { case (chnk, remaining) => - checkTwoNonEmpty(c, chnk, remaining) - } - else checkTwoNonEmpty(c, chnk, remaining) + checkTwoNonEmpty(c, chnk, remaining) case None => Pull.raiseError[F](MalformedMessageBodyFailure("Malformed Multipart ending")) } @@ -375,10 +294,10 @@ object MultipartParser { /** Take the stream of headers separated by * double CRLF bytes and return the headers */ - private def parseHeaders[F[_]: Sync](strim: Stream[F, Byte]): F[Headers] = { + private def parseHeaders[F[_]: Concurrent](strim: Stream[F, Byte]): F[Headers] = { def tailrecParse(s: Stream[F, Byte], headers: Headers): Pull[F, Headers, Unit] = splitHalf[F](CRLFBytesN, s).flatMap { case (l, r) => - l.through(fs2.text.utf8Decode[F]) + l.through(fs2.text.utf8.decode[F]) .fold("")(_ ++ _) .map { string => val ix = string.indexOf(':') @@ -545,273 +464,363 @@ object MultipartParser { /** Same as the other streamed parsing, except * after a particular size, it buffers on a File. */ - def parseStreamedFile[F[_]: Sync: ContextShift]( + @deprecated("Use parseSupervisedFile", "0.23") + def parseStreamedFile[F[_]: Concurrent: Files]( boundary: Boundary, - blocker: Blocker, limit: Int = 1024, maxSizeBeforeWrite: Int = 52428800, maxParts: Int = 20, failOnLimit: Boolean = false): Pipe[F, Byte, Multipart[F]] = { st => - ignorePreludeFileStream[F]( - boundary, - st, - limit, - maxSizeBeforeWrite, - maxParts, - failOnLimit, - blocker) - .fold(Vector.empty[Part[F]])(_ :+ _) + st.through( + parseToPartsStreamedFile(boundary, limit, maxSizeBeforeWrite, maxParts, failOnLimit) + ).fold(Vector.empty[Part[F]])(_ :+ _) .map(Multipart(_, boundary)) } - def parseToPartsStreamedFile[F[_]: Sync: ContextShift]( + @deprecated("Use parseSupervisedFile", "0.23") + def parseToPartsStreamedFile[F[_]: Concurrent: Files]( boundary: Boundary, - blocker: Blocker, limit: Int = 1024, maxSizeBeforeWrite: Int = 52428800, maxParts: Int = 20, - failOnLimit: Boolean = false): Pipe[F, Byte, Part[F]] = { st => - ignorePreludeFileStream[F]( - boundary, - st, - limit, - maxSizeBeforeWrite, - maxParts, - failOnLimit, - blocker) + failOnLimit: Boolean = false): Pipe[F, Byte, Part[F]] = { + + val pullParts: Stream[F, Event] => Stream[F, Part[F]] = + Pull + .loop[F, Part[F], Stream[F, Event]]( + _.pull.uncons1.flatMap( + _.traverse { + case (PartStart(headers), s) => + partBodyFileStream(s, maxSizeBeforeWrite) + .flatMap { case (body, rest) => + Pull.output1(Part(headers, body)).as(rest) + } + // Shouldn't happen if the `parseToEventsStream` contract holds. + case (_: PartChunk | PartEnd, _) => + Pull.raiseError(bug("Missing PartStart")) + } + ) + )(_) + .stream + + _.through( + parseEvents[F](boundary, limit) + ).through( + limitParts[F](maxParts, failOnLimit) + ).through(pullParts) } - /** The first part of our streaming stages: - * - * Ignore the prelude and remove the first boundary. Only traverses until the first - * part - */ - private[this] def ignorePreludeFileStream[F[_]: Sync: ContextShift]( - b: Boundary, - stream: Stream[F, Byte], - limit: Int, - maxSizeBeforeWrite: Int, + private[this] def limitParts[F[_]: RaiseThrowable]( maxParts: Int, - failOnLimit: Boolean, - blocker: Blocker): Stream[F, Part[F]] = { - val values = StartLineBytesN(b) + failOnLimit: Boolean): Pipe[F, Event, Event] = { + def go(st: Stream[F, Event], partsCounter: Int): Pull[F, Event, Unit] = + st.pull.uncons1.flatMap { + case Some((event: PartStart, rest)) => + if (partsCounter < maxParts) { + Pull.output1(event) >> go(rest, partsCounter + 1) + } else if (failOnLimit) { + Pull.raiseError[F](MalformedMessageBodyFailure("Parts limit exceeded")) + } else Pull.pure(()) + case Some((event, rest)) => + Pull.output1(event) >> go(rest, partsCounter) + case None => Pull.pure(()) + } - def go(s: Stream[F, Byte], state: Int, strim: Stream[F, Byte]): Pull[F, Part[F], Unit] = - if (state == values.length) - pullPartsFileStream[F]( - b, - strim ++ s, - limit, - maxSizeBeforeWrite, - maxParts, - failOnLimit, - blocker) + go(_, 0).stream + } + + // Consume `PartChunk`s until the first `PartEnd`, produce a stream with all the consumed data. + private[this] def partBodyFileStream[F[_]: Concurrent: Files]( + stream: Stream[F, Event], + maxBeforeWrite: Int + ): Pull[F, Nothing, (Stream[F, Byte], Stream[F, Event])] = { + // Consume `PartChunk`s until the first `PartEnd`, and write all the data into the file. + def streamAndWrite( + s: Stream[F, Event], + lacc: Stream[Pure, Byte], + limitCTR: Int, + fileRef: Path + ): Pull[F, Nothing, Stream[F, Event]] = + if (limitCTR >= maxBeforeWrite) + Pull.eval( + lacc + .through(Files[F].writeAll(fileRef, Flags.Append)) + .compile + .drain) >> streamAndWrite(s, Stream.empty, 0, fileRef) else - s.pull.uncons.flatMap { - case Some((chnk, rest)) => - val (ix, strim) = splitAndIgnorePrev(values, state, chnk) - go(rest, ix, strim) - case None => - Pull.raiseError[F](MalformedMessageBodyFailure("Malformed Malformed match")) + s.pull.uncons1.flatMap { + case Some((PartChunk(chnk), str)) => + streamAndWrite(str, lacc ++ Stream.chunk(chnk), limitCTR + chnk.size, fileRef) + case Some((PartEnd, str)) => + Pull + .eval( + lacc + .through(Files[F].writeAll(fileRef, Flags.Append)) + .compile + .drain + ) + .as(str) + // Shouldn't happen if the `parseToEventsStream` contract holds. + case Some((_: PartStart, _)) | None => + Pull.raiseError(bug("Missing PartEnd")) } - stream.pull.uncons.flatMap { - case Some((chnk, strim)) => - val (ix, rest) = splitAndIgnorePrev(values, 0, chnk) - go(strim, ix, rest) - case None => - Pull.raiseError[F](MalformedMessageBodyFailure("Cannot parse empty stream")) - }.stream + // Consume `PartChunks` until the first `PartEnd`, accumulating the data in memory. + // Produce a stream with all the accumulated data. + // Fall back to `streamAndWrite` after the limit is reached + def go( + s: Stream[F, Event], + lacc: Stream[Pure, Byte], + limitCTR: Int): Pull[F, Nothing, (Stream[F, Byte], Stream[F, Event])] = + if (limitCTR >= maxBeforeWrite) + Pull + .eval(Files[F].tempFile(None, "", "", None).allocated) + .flatMap { case (path, cleanup) => + streamAndWrite(s, lacc, limitCTR, path) + .tupleLeft(Files[F].readAll(path, maxBeforeWrite, Flags.Read).onFinalizeWeak(cleanup)) + .onError { case _ => Pull.eval(cleanup) } + } + else + s.pull.uncons1.flatMap { + case Some((PartChunk(chnk), str)) => + go(str, lacc ++ Stream.chunk(chnk), limitCTR + chnk.size) + case Some((PartEnd, str)) => + Pull.pure((lacc, str)) + // Shouldn't happen if the `parseToEventsStream` contract holds. + case Some((_: PartStart, _)) | None => + Pull.raiseError(bug("Missing PartEnd")) + } + + go(stream, Stream.empty, 0) } - /** @param boundary - * @param s - * @param limit - * @tparam F - * @return + ///////////////////////////////////// + // Resource-safe file-based parser // + ///////////////////////////////////// + + /** Like parseStreamedFile, but the produced parts' resources are managed by the supervisor. */ - private def pullPartsFileStream[F[_]: Sync: ContextShift]( + private[multipart] def parseSupervisedFile[F[_]: Concurrent: Files]( + supervisor: Supervisor[F], boundary: Boundary, - s: Stream[F, Byte], - limit: Int, - maxBeforeWrite: Int, - maxParts: Int, - failOnLimit: Boolean, - blocker: Blocker - ): Pull[F, Part[F], Unit] = { - val values = DoubleCRLFBytesN - val expectedBytes = ExpectedBytesN(boundary) - - splitOrFinish[F](values, s, limit).flatMap { case (l, r) => - //We can abuse reference equality here for efficiency - //Since `splitOrFinish` returns `empty` on a capped stream - //However, we must have at least one part, so `splitOrFinish` on this function - //Indicates an error - if (r == streamEmpty) - Pull.raiseError[F](MalformedMessageBodyFailure("Cannot parse empty stream")) - else - tailrecPartsFileStream[F]( - boundary, - l, - r, - expectedBytes, - limit, - maxBeforeWrite, - 1, - maxParts, - failOnLimit, - blocker - ) - } + limit: Int = 1024, + maxSizeBeforeWrite: Int = 52428800, + maxParts: Int = 20, + failOnLimit: Boolean = false, + chunkSize: Int = 8192 + ): Pipe[F, Byte, Multipart[F]] = { st => + st.through( + parseToPartsSupervisedFile( + supervisor, + boundary, + limit, + maxSizeBeforeWrite, + maxParts, + failOnLimit, + chunkSize) + ).fold(Vector.empty[Part[F]])(_ :+ _) + .map(Multipart(_, boundary)) } - private[this] def cleanupFileOption[F[_]](p: Option[Path])(implicit - F: Sync[F]): Pull[F, Nothing, Unit] = - p match { - case Some(path) => - Pull.eval(cleanupFile(path)) + private[multipart] def parseToPartsSupervisedFile[F[_]]( + supervisor: Supervisor[F], + boundary: Boundary, + limit: Int = 1024, + maxSizeBeforeWrite: Int = 52428800, + maxParts: Int = 20, + failOnLimit: Boolean = false, + chunkSize: Int = 8192 + )(implicit F: Concurrent[F], files: Files[F]): Pipe[F, Byte, Part[F]] = { + val createFile = superviseResource(supervisor, files.tempFile) + def append(file: Path, bytes: Stream[Pure, Byte]): F[Unit] = + bytes.through(files.writeAll(file, Flags.Append)).compile.drain + + final case class Acc(file: Option[Path], bytes: Stream[Pure, Byte], bytesSize: Int) + + def stepPartChunk(oldAcc: Acc, chunk: Chunk[Byte]): F[Acc] = { + val newSize = oldAcc.bytesSize + chunk.size + val newBytes = oldAcc.bytes ++ Stream.chunk(chunk) + if (newSize > maxSizeBeforeWrite) { + oldAcc.file + .fold(createFile)(F.pure) + .flatTap(append(_, newBytes)) + .map(newFile => Acc(Some(newFile), Stream.empty, 0)) + } else F.pure(Acc(oldAcc.file, newBytes, newSize)) + } - case None => - PullUnit //Todo: Move to fs2 + val stepPartEnd: Acc => F[Stream[F, Byte]] = { + case Acc(None, bytes, _) => F.pure(bytes) + case Acc(Some(file), bytes, size) => + append(file, bytes) + .whenA(size > 0) + .as( + files.readAll(file, chunkSize = chunkSize, Flags.Read) + ) } - private[this] def cleanupFile[F[_]](path: Path)(implicit F: Sync[F]): F[Unit] = - F.delay(Files.delete(path)) - .handleErrorWith { err => - logger.error(err)("Caught error during file cleanup for multipart") - //Swallow and report io exceptions in case - F.unit + val step: (Option[(Headers, Acc)], Event) => F[(Option[(Headers, Acc)], Option[Part[F]])] = { + case (None, PartStart(headers)) => + val newAcc = Acc(None, Stream.empty, 0) + F.pure((Some((headers, newAcc)), None)) + // Shouldn't happen if the `parseToEventsStream` contract holds. + case (None, (_: PartChunk | PartEnd)) => + F.raiseError(bug("Missing PartStart")) + case (Some((headers, oldAcc)), PartChunk(chunk)) => + stepPartChunk(oldAcc, chunk).map { newAcc => + (Some((headers, newAcc)), None) + } + case (Some((headers, acc)), PartEnd) => + // Part done - emit it and start over. + stepPartEnd(acc) + .map(body => (None, Some(Part(headers, body)))) + // Shouldn't happen if the `parseToEventsStream` contract holds. + case (Some(_), _: PartStart) => + F.raiseError(bug("Missing PartEnd")) + } + + _.through( + parseEvents(boundary, limit) + ).through( + limitParts(maxParts, failOnLimit) + ).evalMapAccumulate(none[(Headers, Acc)])(step) + .mapFilter(_._2) + } + + // Acquire the resource in a separate fiber, which will remain running until the provided + // supervisor sees fit to cancel it. The resulting action waits for the resource to be acquired. + private[this] def superviseResource[F[_], A]( + supervisor: Supervisor[F], + resource: Resource[F, A] + )(implicit F: Concurrent[F]): F[A] = + F.deferred[Either[Throwable, A]].flatMap { deferred => + supervisor.supervise[Nothing]( + resource.attempt + .evalTap(deferred.complete) + // In case of an error the exception brings down the fiber. + .rethrow + // Success - keep the resource alive until the supervisor cancels this fiber. + .useForever + ) *> deferred.get.rethrow + } + + //////////////////////////// + // Streaming event parser // + //////////////////////////// + + /** Parse a stream of bytes into a stream of part events. The events come in the following order: + * + * - one `PartStart`; + * - any number of `PartChunk`s; + * - one `PartEnd`. + * + * Any number of such sequences may be produced. + */ + private[this] def parseEvents[F[_]: Concurrent]( + boundary: Boundary, + headerLimit: Int + ): Pipe[F, Byte, Event] = + skipPrelude(boundary, _) + .flatMap(pullPartsEvents(boundary, _, headerLimit)) + .stream + + /** Drain the prelude and remove the first boundary. Only traverses until the first + * part. + */ + private[this] def skipPrelude[F[_]: Concurrent]( + boundary: Boundary, + stream: Stream[F, Byte] + ): Pull[F, Nothing, Stream[F, Byte]] = { + val dashBoundaryBytes = StartLineBytesN(boundary) + + def go(s: Stream[F, Byte], state: Int): Pull[F, Nothing, Stream[F, Byte]] = + s.pull.uncons.flatMap { + case Some((chnk, rest)) => + val (ix, remainder) = splitAndIgnorePrev(dashBoundaryBytes, state, chnk) + if (ix === dashBoundaryBytes.length) Pull.pure(remainder ++ rest) + else go(rest, ix) + case None => + Pull.raiseError[F](MalformedMessageBodyFailure("Malformed Malformed match")) + } + + go(stream, 0) + } + + /** Pull part events for parts until the end of the stream. */ + private[this] def pullPartsEvents[F[_]: Concurrent]( + boundary: Boundary, + stream: Stream[F, Byte], + headerLimit: Int + ): Pull[F, Event, Unit] = { + val delimiterBytes = ExpectedBytesN(boundary) + + // Headers on the left, the remainder on the right. + type Acc = (Stream[F, Byte], Stream[F, Byte]) + val pullPartEvents0: Acc => Pull[F, Event, Stream[F, Byte]] = + (pullPartEvents[F](_, _, delimiterBytes)).tupled + + splitOrFinish[F](DoubleCRLFBytesN, stream, headerLimit) + // We must have at least one part. + .ensure(MalformedMessageBodyFailure("Cannot parse empty stream")) { + // We can abuse reference equality here for efficiency, since `splitOrFinish` + // returns `empty` on a capped stream. + case (_, rest) => rest != streamEmpty } + .flatMap( + _.iterateWhileM { acc => + pullPartEvents0(acc).flatMap( + splitOrFinish( + DoubleCRLFBytesN, + _, + headerLimit + ) + ) + } { case (_, rest) => rest != streamEmpty }.void + ) + } - private[this] def tailrecPartsFileStream[F[_]: Sync: ContextShift]( - b: Boundary, + /** Pulls part events for a single part. */ + private[this] def pullPartEvents[F[_]: Concurrent]( headerStream: Stream[F, Byte], rest: Stream[F, Byte], - expectedBytes: Array[Byte], - headerLimit: Int, - maxBeforeWrite: Int, - partsCounter: Int, - partsLimit: Int, - failOnLimit: Boolean, - blocker: Blocker): Pull[F, Part[F], Unit] = + delimiterBytes: Array[Byte] + ): Pull[F, Event, Stream[F, Byte]] = Pull .eval(parseHeaders(headerStream)) - .flatMap { hdrs => - splitWithFileStream(expectedBytes, rest, maxBeforeWrite, blocker).flatMap { - case (partBody, rest, fileRef) => - //We hit a boundary, but the rest of the stream is empty - //and thus it's not a properly capped multipart body - if (rest == streamEmpty) - cleanupFileOption[F](fileRef) >> Pull.raiseError[F]( - MalformedMessageBodyFailure("Part not terminated properly")) - else - Pull.output1(makePart(hdrs, partBody, fileRef)) >> splitOrFinish( - DoubleCRLFBytesN, - rest, - headerLimit) - .flatMap { case (hdrStream, remaining) => - if (hdrStream == streamEmpty) //Empty returned if it worked fine - Pull.done - else if (partsCounter >= partsLimit) - if (failOnLimit) - Pull.raiseError[F](MalformedMessageBodyFailure("Parts limit exceeded")) - else - Pull.done - else - tailrecPartsFileStream[F]( - b, - hdrStream, - remaining, - expectedBytes, - headerLimit, - maxBeforeWrite, - partsCounter + 1, - partsLimit, - failOnLimit, - blocker) - .handleErrorWith(e => cleanupFileOption(fileRef) >> Pull.raiseError[F](e)) - } - } + .flatMap(headers => Pull.output1(PartStart(headers): Event)) + .productR(pullPartChunks(delimiterBytes, rest)) + .flatMap { case rest => + // We hit a boundary, but the rest of the stream is empty + // and thus it's not a properly capped multipart body + if (rest == streamEmpty) + Pull.raiseError[F](MalformedMessageBodyFailure("Part not terminated properly")) + else + Pull.output1(PartEnd).as(rest) } - private[this] def makePart[F[_]](hdrs: Headers, body: Stream[F, Byte], path: Option[Path])( - implicit F: Sync[F]): Part[F] = - path match { - case Some(p) => Part(hdrs, body.onFinalizeWeak(F.delay(Files.delete(p)))) - case None => Part(hdrs, body) - } - - /** Split the stream on `values`, but when - */ - private def splitWithFileStream[F[_]]( - values: Array[Byte], - stream: Stream[F, Byte], - maxBeforeWrite: Int, - blocker: Blocker)(implicit F: Sync[F], cs: ContextShift[F]): SplitFileStream[F] = { - def streamAndWrite( - s: Stream[F, Byte], - state: Int, - lacc: Stream[F, Byte], - racc: Stream[F, Byte], - limitCTR: Int, - fileRef: Path): SplitFileStream[F] = - if (state == values.length) - Pull.eval( - lacc - .through(writeAll[F](fileRef, blocker, List(StandardOpenOption.APPEND))) - .compile - .drain) >> Pull.pure( - (readAll[F](fileRef, blocker, maxBeforeWrite), racc ++ s, Some(fileRef))) - else if (limitCTR >= maxBeforeWrite) - Pull.eval( - lacc - .through(writeAll[F](fileRef, blocker, List(StandardOpenOption.APPEND))) - .compile - .drain) >> streamAndWrite(s, state, Stream.empty, racc, 0, fileRef) - else - s.pull.uncons.flatMap { - case Some((chnk, str)) => - val (ix, l, r, add) = splitOnChunkLimited[F](values, state, chnk, lacc, racc) - streamAndWrite(str, ix, l, r, limitCTR + add, fileRef) - case None => - Pull.eval(F.delay(Files.delete(fileRef)).attempt) >> Pull.raiseError[F]( - MalformedMessageBodyFailure("Invalid boundary - partial boundary")) - } - + /** Split the stream on `delimiterBytes`, emitting the left part as `PartChunk` events. */ + private[this] def pullPartChunks[F[_]: Concurrent]( + delimiterBytes: Array[Byte], + stream: Stream[F, Byte] + ): Pull[F, PartChunk, Stream[F, Byte]] = { def go( s: Stream[F, Byte], state: Int, - lacc: Stream[F, Byte], - racc: Stream[F, Byte], - limitCTR: Int): SplitFileStream[F] = - if (limitCTR >= maxBeforeWrite) - Pull - .eval(F.delay(Files.createTempFile("", ""))) - .flatMap { path => - (for { - _ <- Pull.eval(lacc.through(writeAll[F](path, blocker)).compile.drain) - split <- streamAndWrite(s, state, Stream.empty, racc, 0, path) - } yield split) - .handleErrorWith(e => Pull.eval(cleanupFile(path)) >> Pull.raiseError[F](e)) - } - else if (state == values.length) - Pull.pure((lacc, racc ++ s, None)) + racc: Stream[F, Byte] + ): Pull[F, PartChunk, Stream[F, Byte]] = + if (state == delimiterBytes.length) + Pull.pure(racc ++ s) else s.pull.uncons.flatMap { - case Some((chnk, str)) => - val (ix, l, r, add) = splitOnChunkLimited[F](values, state, chnk, lacc, racc) - go(str, ix, l, r, limitCTR + add) + case Some((chnk, rest)) => + val (ix, l, r) = splitOnChunk[F](delimiterBytes, state, chnk, Stream.empty, racc) + l.chunks.map(PartChunk(_)).pull.echo >> { + if (ix == delimiterBytes.length) Pull.pure(r ++ rest) + else go(rest, ix, r) + } case None => Pull.raiseError[F](MalformedMessageBodyFailure("Invalid boundary - partial boundary")) } - stream.pull.uncons.flatMap { - case Some((chunk, rest)) => - val (ix, l, r, add) = - splitOnChunkLimited[F](values, 0, chunk, Stream.empty, Stream.empty) - go(rest, ix, l, r, add) - case None => - Pull.raiseError[F](MalformedMessageBodyFailure("Invalid boundary - partial boundary")) - } + go(stream, 0, Stream.empty) } } diff --git a/core/src/main/scala/org/http4s/multipart/Part.scala b/core/src/main/scala/org/http4s/multipart/Part.scala index 1677f4d1703..01f859dfd27 100644 --- a/core/src/main/scala/org/http4s/multipart/Part.scala +++ b/core/src/main/scala/org/http4s/multipart/Part.scala @@ -17,11 +17,11 @@ package org.http4s package multipart -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.Sync import fs2.Stream import fs2.io.readInputStream -import fs2.io.file.readAll -import fs2.text.utf8Encode +import fs2.io.file.{Files, Flags, Path} +import fs2.text.utf8 import java.io.{File, InputStream} import java.net.URL import org.http4s.headers.`Content-Disposition` @@ -38,33 +38,20 @@ final case class Part[F[_]](headers: Headers, body: Stream[F, Byte]) extends Med object Part { private val ChunkSize = 8192 - @deprecated( - """Empty parts are not allowed by the multipart spec, see: https://tools.ietf.org/html/rfc7578#section-4.2 - -Moreover, it allows the creation of potentially incorrect multipart bodies""", - "0.18.12" - ) - def empty[F[_]]: Part[F] = - Part(Headers.empty, EmptyBody) - def formData[F[_]](name: String, value: String, headers: Header.ToRaw*): Part[F] = Part( Headers(`Content-Disposition`("form-data", Map(ci"name" -> name))).put(headers: _*), - Stream.emit(value).through(utf8Encode)) + Stream.emit(value).through(utf8.encode)) - def fileData[F[_]: Sync: ContextShift]( - name: String, - file: File, - blocker: Blocker, - headers: Header.ToRaw*): Part[F] = - fileData(name, file.getName, readAll[F](file.toPath, blocker, ChunkSize), headers: _*) + def fileData[F[_]: Files](name: String, file: File, headers: Header.ToRaw*): Part[F] = + fileData( + name, + file.getName, + Files[F].readAll(Path.fromNioPath(file.toPath), ChunkSize, Flags.Read), + headers: _*) - def fileData[F[_]: Sync: ContextShift]( - name: String, - resource: URL, - blocker: Blocker, - headers: Header.ToRaw*): Part[F] = - fileData(name, resource.getPath.split("/").last, resource.openStream(), blocker, headers: _*) + def fileData[F[_]: Sync](name: String, resource: URL, headers: Header.ToRaw*): Part[F] = + fileData(name, resource.getPath.split("/").last, resource.openStream(), headers: _*) def fileData[F[_]]( name: String, @@ -87,7 +74,6 @@ Moreover, it allows the creation of potentially incorrect multipart bodies""", name: String, filename: String, in: => InputStream, - blocker: Blocker, - headers: Header.ToRaw*)(implicit F: Sync[F], cs: ContextShift[F]): Part[F] = - fileData(name, filename, readInputStream(F.delay(in), ChunkSize, blocker), headers: _*) + headers: Header.ToRaw*)(implicit F: Sync[F]): Part[F] = + fileData(name, filename, readInputStream(F.delay(in), ChunkSize), headers: _*) } diff --git a/core/src/main/scala/org/http4s/package.scala b/core/src/main/scala/org/http4s/package.scala index 9571f462242..16498d6f0b5 100644 --- a/core/src/main/scala/org/http4s/package.scala +++ b/core/src/main/scala/org/http4s/package.scala @@ -79,9 +79,4 @@ package object http4s { /** A stream of server-sent events */ type EventStream[F[_]] = Stream[F, ServerSentEvent] - - @deprecated("Moved to org.http4s.syntax.AllSyntax", "0.16") - type Http4sSyntax = syntax.AllSyntax - @deprecated("Moved to org.http4s.syntax.all", "0.16") - val Http4sSyntax = syntax.all } diff --git a/core/src/main/scala/org/http4s/syntax/AllSyntax.scala b/core/src/main/scala/org/http4s/syntax/AllSyntax.scala index 6b999cf5f65..65b9f4f3922 100644 --- a/core/src/main/scala/org/http4s/syntax/AllSyntax.scala +++ b/core/src/main/scala/org/http4s/syntax/AllSyntax.scala @@ -17,11 +17,6 @@ package org.http4s package syntax -abstract class AllSyntaxBinCompat - extends AllSyntax - with KleisliSyntaxBinCompat0 - with KleisliSyntaxBinCompat1 - trait AllSyntax extends AnyRef with KleisliSyntax diff --git a/core/src/main/scala/org/http4s/syntax/KleisliSyntax.scala b/core/src/main/scala/org/http4s/syntax/KleisliSyntax.scala index eb92e3c40b5..0ce4760921e 100644 --- a/core/src/main/scala/org/http4s/syntax/KleisliSyntax.scala +++ b/core/src/main/scala/org/http4s/syntax/KleisliSyntax.scala @@ -17,26 +17,21 @@ package org.http4s package syntax -import cats.{Functor, ~>} +import cats.{Functor, Monad, ~>} import cats.syntax.functor._ -import cats.effect.Sync import cats.data.{Kleisli, OptionT} trait KleisliSyntax { implicit def http4sKleisliResponseSyntaxOptionT[F[_]: Functor, A]( kleisli: Kleisli[OptionT[F, *], A, Response[F]]): KleisliResponseOps[F, A] = new KleisliResponseOps[F, A](kleisli) -} -trait KleisliSyntaxBinCompat0 { implicit def http4sKleisliHttpRoutesSyntax[F[_]](routes: HttpRoutes[F]): KleisliHttpRoutesOps[F] = new KleisliHttpRoutesOps[F](routes) - implicit def http4sKleisliHttpAppSyntax[F[_]: Sync](app: HttpApp[F]): KleisliHttpAppOps[F] = + implicit def http4sKleisliHttpAppSyntax[F[_]: Functor](app: HttpApp[F]): KleisliHttpAppOps[F] = new KleisliHttpAppOps[F](app) -} -trait KleisliSyntaxBinCompat1 { implicit def http4sKleisliAuthedRoutesSyntax[F[_], A]( authedRoutes: AuthedRoutes[A, F]): KleisliAuthedRoutesOps[F, A] = new KleisliAuthedRoutesOps[F, A](authedRoutes) @@ -48,16 +43,16 @@ final class KleisliResponseOps[F[_]: Functor, A](self: Kleisli[OptionT[F, *], A, } final class KleisliHttpRoutesOps[F[_]](self: HttpRoutes[F]) { - def translate[G[_]: Sync](fk: F ~> G)(gK: G ~> F): HttpRoutes[G] = + def translate[G[_]: Monad](fk: F ~> G)(gK: G ~> F): HttpRoutes[G] = HttpRoutes(request => self.run(request.mapK(gK)).mapK(fk).map(_.mapK(fk))) } -final class KleisliHttpAppOps[F[_]: Sync](self: HttpApp[F]) { - def translate[G[_]: Sync](fk: F ~> G)(gK: G ~> F): HttpApp[G] = +final class KleisliHttpAppOps[F[_]: Functor](self: HttpApp[F]) { + def translate[G[_]: Monad](fk: F ~> G)(gK: G ~> F): HttpApp[G] = HttpApp(request => fk(self.run(request.mapK(gK)).map(_.mapK(fk)))) } final class KleisliAuthedRoutesOps[F[_], A](self: AuthedRoutes[A, F]) { - def translate[G[_]: Sync](fk: F ~> G)(gK: G ~> F): AuthedRoutes[A, G] = + def translate[G[_]: Monad](fk: F ~> G)(gK: G ~> F): AuthedRoutes[A, G] = AuthedRoutes(authedReq => self.run(authedReq.mapK(gK)).mapK(fk).map(_.mapK(fk))) } diff --git a/core/src/main/scala/org/http4s/syntax/package.scala b/core/src/main/scala/org/http4s/syntax/package.scala index 7cc73d77a8f..e8265795f64 100644 --- a/core/src/main/scala/org/http4s/syntax/package.scala +++ b/core/src/main/scala/org/http4s/syntax/package.scala @@ -17,9 +17,9 @@ package org.http4s package object syntax { - object all extends AllSyntaxBinCompat + object all extends AllSyntax @deprecated("import is no longer needed", "0.22.3") - object kleisli extends KleisliSyntax with KleisliSyntaxBinCompat0 with KleisliSyntaxBinCompat1 + object kleisli extends KleisliSyntax object literals extends LiteralsSyntax object string extends StringSyntax object header extends HeaderSyntax diff --git a/docs/src/hugo/config.toml b/docs/src/hugo/config.toml index 743f50f1bb1..f44de22cc64 100644 --- a/docs/src/hugo/config.toml +++ b/docs/src/hugo/config.toml @@ -1,4 +1,4 @@ -baseurl = "https://http4s.org/v0.22" +baseurl = "https://http4s.org/v0.23" contentDir = "../../target/mdoc" dataDir = "../../target/hugo-data" languageCode = "en-us" diff --git a/docs/src/main/mdoc/client.md b/docs/src/main/mdoc/client.md index b45c84e8dac..157087f3238 100644 --- a/docs/src/main/mdoc/client.md +++ b/docs/src/main/mdoc/client.md @@ -34,14 +34,12 @@ import org.http4s.implicits._ import org.http4s.blaze.server._ ``` -Blaze needs a [[`ConcurrentEffect`]] instance, which is derived from -[[`ContextShift`]]. The following lines are not necessary if you are -in an [[`IOApp`]]: +The following is provided by an `IOApp`, but necessary if following +along in a REPL: ```scala mdoc:silent:nest -import scala.concurrent.ExecutionContext.global -implicit val cs: ContextShift[IO] = IO.contextShift(global) -implicit val timer: Timer[IO] = IO.timer(global) +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global ``` Finish setting up our server: @@ -95,12 +93,10 @@ It uses blocking IO and is less suited for production, but it is highly useful in a REPL: ```scala mdoc:silent:nest -import cats.effect.Blocker import java.util.concurrent._ val blockingPool = Executors.newFixedThreadPool(5) -val blocker = Blocker.liftExecutorService(blockingPool) -val httpClient: Client[IO] = JavaNetClientBuilder[IO](blocker).create +val httpClient: Client[IO] = JavaNetClientBuilder[IO].create ``` ### Describing a call @@ -239,7 +235,6 @@ import org.http4s.metrics.dropwizard.Dropwizard import com.codahale.metrics.SharedMetricRegistries ``` ```scala mdoc:nest -implicit val clock = Clock.create[IO] val registry = SharedMetricRegistries.getOrCreate("default") val requestMethodClassifier = (r: Request[IO]) => Some(r.method.toString.toLowerCase) @@ -269,7 +264,6 @@ import org.http4s.client.middleware.Metrics import org.http4s.metrics.prometheus.Prometheus ``` ```scala mdoc:nest -implicit val clock = Clock.create[IO] val requestMethodClassifier = (r: Request[IO]) => Some(r.method.toString.toLowerCase) val meteredClient: Resource[IO, Client[IO]] = @@ -382,8 +376,6 @@ blockingPool.shutdown() [service]: ../service [entity]: ../entity [json]: ../json -[`ContextShift`]: https://typelevel.org/cats-effect/datatypes/contextshift.html -[`ConcurrentEffect`]: https://typelevel.org/cats-effect/typeclasses/concurrent-effect.html [`IOApp`]: https://typelevel.org/cats-effect/datatypes/ioapp.html [middleware]: ../middleware [Follow Redirect]: ../api/org/http4s/client/middleware/FollowRedirect$ diff --git a/docs/src/main/mdoc/cors.md b/docs/src/main/mdoc/cors.md index 2acd6325f37..91996c1cd9c 100644 --- a/docs/src/main/mdoc/cors.md +++ b/docs/src/main/mdoc/cors.md @@ -34,6 +34,13 @@ import org.http4s.dsl.io._ import org.http4s.implicits._ ``` +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global +``` + Let's start by making a simple service. ```scala mdoc diff --git a/docs/src/main/mdoc/csrf.md b/docs/src/main/mdoc/csrf.md index d32b48fc23e..3d358e9a319 100644 --- a/docs/src/main/mdoc/csrf.md +++ b/docs/src/main/mdoc/csrf.md @@ -26,6 +26,13 @@ import org.http4s.implicits._ import org.http4s.server.middleware._ ``` +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global +``` + Let's start by making a simple service. ```scala mdoc diff --git a/docs/src/main/mdoc/dsl.md b/docs/src/main/mdoc/dsl.md index c235091cd98..bd5a91d3a48 100644 --- a/docs/src/main/mdoc/dsl.md +++ b/docs/src/main/mdoc/dsl.md @@ -39,8 +39,13 @@ We'll need the following imports to get started: import cats.effect._ import cats.syntax.all._ import org.http4s._, org.http4s.dsl.io._, org.http4s.implicits._ -// Provided by `cats.effect.IOApp` -implicit val timer : Timer[IO] = IO.timer(scala.concurrent.ExecutionContext.global) +``` + +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global ``` The central concept of http4s-dsl is pattern matching. An @@ -214,12 +219,11 @@ NoContent("does not compile") #### Asynchronous responses -While http4s prefers `F[_]: Effect`, you may be working with libraries that +While http4s prefers `F[_]: Async`, you may be working with libraries that use standard library `Future`s. Some relevant imports: ```scala mdoc import scala.concurrent.Future -import scala.concurrent.ExecutionContext import scala.concurrent.ExecutionContext.Implicits.global ``` @@ -233,8 +237,6 @@ effectful, unless we wrap it in `IO`: suspended future is shifted to the correct thread pool. ```scala mdoc:nest -implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global) - val io = Ok(IO.fromFuture(IO(Future { println("I run when the future is constructed.") "Greetings from the future!" @@ -281,7 +283,7 @@ val drip: Stream[IO, String] = We can see it for ourselves in the REPL: ```scala mdoc -val dripOutIO = drip.through(fs2.text.lines).through(_.evalMap(s => {IO{println(s); s}})).compile.drain +val dripOutIO = drip.through(fs2.text.lines).evalMap(s => {IO{println(s); s}}).compile.drain dripOutIO.unsafeRunSync() ``` @@ -350,7 +352,7 @@ Alice!" to `GET /hello/Alice`: ```scala mdoc:silent HttpRoutes.of[IO] { - case GET -> Root / "hello" / name => Ok(s"Hello, $name!") + case GET -> Root / "hello" / name => Ok(s"Hello ${name}!") } ``` @@ -488,11 +490,6 @@ val averageTemperatureService = HttpRoutes.of[IO] { To support a `QueryParamDecoderMatcher[Instant]`, consider `QueryParamCodec#instantQueryParamCodec`. That outputs a `QueryParamCodec[Instant]`, which offers both a `QueryParamEncoder[Instant]` and `QueryParamDecoder[Instant]`. -```scala mdoc:silent -import java.time.Instant -import java.time.format.DateTimeFormatter -``` - ```scala mdoc:silent:warn implicit val isoInstantCodec: QueryParamCodec[Instant] = QueryParamCodec.instantQueryParamCodec(DateTimeFormatter.ISO_INSTANT) @@ -506,7 +503,6 @@ To accept an optional query parameter a `OptionalQueryParamDecoderMatcher` can b ```scala mdoc:silent import java.time.Year -import org.http4s.client.dsl.io._ ``` ```scala mdoc:nest diff --git a/docs/src/main/mdoc/entity.md b/docs/src/main/mdoc/entity.md index 852a5f926aa..ff1ae2c2d34 100644 --- a/docs/src/main/mdoc/entity.md +++ b/docs/src/main/mdoc/entity.md @@ -55,6 +55,13 @@ case class Audio(body: String) extends Resp case class Video(body: String) extends Resp ``` +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global +``` + ```scala mdoc val response = Ok("").map(_.withContentType(`Content-Type`(MediaType.audio.ogg))) val audioDec = EntityDecoder.decodeBy(MediaType.audio.ogg) { (m: Media[IO]) => diff --git a/docs/src/main/mdoc/gzip.md b/docs/src/main/mdoc/gzip.md index 9264e9826e6..64680741030 100644 --- a/docs/src/main/mdoc/gzip.md +++ b/docs/src/main/mdoc/gzip.md @@ -25,6 +25,13 @@ import org.http4s.dsl.io._ import org.http4s.implicits._ ``` +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global +``` + Let's start by making a simple service that returns a (relatively) large string in its body. We'll use `as[String]` to examine the body. diff --git a/docs/src/main/mdoc/hsts.md b/docs/src/main/mdoc/hsts.md index 295dad18cbb..e51d15f7ea6 100644 --- a/docs/src/main/mdoc/hsts.md +++ b/docs/src/main/mdoc/hsts.md @@ -26,6 +26,13 @@ import org.http4s.implicits._ import cats.effect.IO ``` +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global +``` + Let's make a simple service that will be exposed and wrapped with HSTS. ```scala mdoc diff --git a/docs/src/main/mdoc/index.md b/docs/src/main/mdoc/index.md index d4e175ab980..9f08ee0d9d7 100644 --- a/docs/src/main/mdoc/index.md +++ b/docs/src/main/mdoc/index.md @@ -13,7 +13,7 @@ Getting started with http4s is easy. Let's materialize an http4s skeleton project from its [giter8 template]: ```sbt -$ sbt -sbt-version 1.3.12 new http4s/http4s.g8 -b 0.22 +$ sbt -sbt-version 1.3.12 new http4s/http4s.g8 -b 0.23 ``` Follow the prompts. For every step along the way, a default value is diff --git a/docs/src/main/mdoc/json.md b/docs/src/main/mdoc/json.md index 205c686581a..dd9e62748f6 100644 --- a/docs/src/main/mdoc/json.md +++ b/docs/src/main/mdoc/json.md @@ -28,6 +28,13 @@ libraryDependencies ++= Seq( addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) ``` +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global +``` + ## Sending raw JSON Let's create a function to produce a simple JSON greeting with circe. First, the imports: @@ -225,10 +232,6 @@ import scala.concurrent.ExecutionContext.Implicits.global case class User(name: String) case class Hello(greeting: String) -// Needed by `BlazeServerBuilder`. Provided by `IOApp`. -implicit val cs: ContextShift[IO] = IO.contextShift(global) -implicit val timer: Timer[IO] = IO.timer(global) - implicit val decoder = jsonOf[IO, User] val jsonApp = HttpRoutes.of[IO] { @@ -242,6 +245,10 @@ val jsonApp = HttpRoutes.of[IO] { }.orNotFound val server = BlazeServerBuilder[IO](global).bindHttp(8080).withHttpApp(jsonApp).resource + +// This is typically provided by IOApp +implicit val runtime: cats.effect.unsafe.IORuntime = cats.effect.unsafe.IORuntime.global + val fiber = server.use(_ => IO.never).start.unsafeRunSync() ``` diff --git a/docs/src/main/mdoc/middleware.md b/docs/src/main/mdoc/middleware.md index ac55bb2b3c8..09ae50060d8 100644 --- a/docs/src/main/mdoc/middleware.md +++ b/docs/src/main/mdoc/middleware.md @@ -32,6 +32,14 @@ import org.http4s.dsl.io._ import org.http4s.implicits._ ``` + +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global +``` + Then, we can create a middleware that adds a header to successful responses from the wrapped service like this. @@ -177,7 +185,6 @@ import org.http4s.metrics.dropwizard.Dropwizard import com.codahale.metrics.SharedMetricRegistries ``` ```scala mdoc -implicit val clock = Clock.create[IO] val registry = SharedMetricRegistries.getOrCreate("default") val meteredRoutes = Metrics[IO](Dropwizard(registry, "server"))(apiService) @@ -198,15 +205,13 @@ We can create a middleware that registers metrics prefixed with a provided prefix like this. ```scala mdoc:silent -import cats.effect.{Clock, IO, Resource} +import cats.effect.{IO, Resource} import org.http4s.HttpRoutes import org.http4s.metrics.prometheus.{Prometheus, PrometheusExportService} import org.http4s.server.Router import org.http4s.server.middleware.Metrics ``` ```scala mdoc:nest -implicit val clock = Clock.create[IO] - val meteredRouter: Resource[IO, HttpRoutes[IO]] = for { metricsSvc <- PrometheusExportService.build[IO] @@ -216,7 +221,6 @@ val meteredRouter: Resource[IO, HttpRoutes[IO]] = "/" -> metricsSvc.routes ) } yield router - ``` ### X-Request-ID Middleware diff --git a/docs/src/main/mdoc/service.md b/docs/src/main/mdoc/service.md index 2f8d6f32d4d..66d70bffef2 100644 --- a/docs/src/main/mdoc/service.md +++ b/docs/src/main/mdoc/service.md @@ -56,12 +56,11 @@ Wherever you are in your studies, let's create our first import cats.effect._, org.http4s._, org.http4s.dsl.io._, scala.concurrent.ExecutionContext.Implicits.global ``` -You also will need a `ContextShift` and a `Timer`. These come for -free if you are in an `IOApp`. +If you're in a REPL, we also need a runtime. This comes for free in `IOApp`: -```scala mdoc:silent -implicit val cs: ContextShift[IO] = IO.contextShift(global) -implicit val timer: Timer[IO] = IO.timer(global) +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global ``` Using the [http4s-dsl], we can construct an `HttpRoutes` by pattern diff --git a/docs/src/main/mdoc/static.md b/docs/src/main/mdoc/static.md index 8cf769b1175..31e25421f43 100644 --- a/docs/src/main/mdoc/static.md +++ b/docs/src/main/mdoc/static.md @@ -31,10 +31,9 @@ object SimpleHttpServer extends IOApp { val app: Resource[IO, Server] = for { - blocker <- Blocker[IO] server <- BlazeServerBuilder[IO](global) .bindHttp(8080) - .withHttpApp(fileService[IO](FileService.Config(".", blocker)).orNotFound) + .withHttpApp(fileService[IO](FileService.Config(".")).orNotFound) .resource } yield server } @@ -45,7 +44,7 @@ Static content services can be composed into a larger application by using a `Ro val httpApp: HttpApp[IO] = Router( "api" -> anotherService, - "assets" -> fileService(FileService.Config("./assets", blocker)) + "assets" -> fileService(FileService.Config("./assets")) ).orNotFound ``` @@ -56,42 +55,10 @@ a file version. So the next time the browser requests that information, it sends the ETag along, and gets a 304 Not Modified back, so you don't have to send the data over the wire again. -### Execution Context - -Static file support uses a blocking API, so we'll need a blocking execution -context. For this reason, the helpers in `org.http4s.server.staticcontent._` takes -an argument of type `cats.effect.Blocker`. -You can create a `Resource[F, Blocker]` by calling `Blocker[F]`, which will handle -creating and disposing of an underlying thread pool. You can also create your -own by lifting an execution context or an executor service. - -For now, we will lift an executor service, since using `Resource` in a [mdoc] -example is not feasible. - -```scala mdoc:silent:nest -import java.util.concurrent._ - -val blockingPool = Executors.newFixedThreadPool(4) -val blocker = Blocker.liftExecutorService(blockingPool) -``` - -It also needs a main thread pool to shift back to. This is provided when -we're in IOApp, but you'll need one if you're following along in a REPL: - -```scala mdoc:silent:nest -import scala.concurrent.ExecutionContext - -implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global) -``` - -In a production application, `ContextShift[IO]` will be supplied by `IOApp` -and the blocker would be created at app startup, using the `Resource` approach. - -```scala mdoc:silent:nest -val routes = fileService[IO](FileService.Config(".", blocker)) -``` +## Inline in a route For custom behaviour, `StaticFile.fromFile` can also be used directly in a route, to respond with a file: + ```scala mdoc:silent:nest import org.http4s._ import org.http4s.dsl.io._ @@ -99,7 +66,7 @@ import java.io.File val routes = HttpRoutes.of[IO] { case request @ GET -> Root / "index.html" => - StaticFile.fromFile(new File("relative/path/to/index.html"), blocker, Some(request)) + StaticFile.fromFile(new File("relative/path/to/index.html"), Some(request)) .getOrElseF(NotFound()) // In case the file doesn't exist } ``` @@ -110,19 +77,19 @@ For simple file serving, it's possible to package resources with the jar and deliver them from there. For example, for all resources in the classpath under `assets`: ```scala mdoc:nest -val routes = resourceServiceBuilder[IO]("/assets", blocker).toRoutes +val routes = resourceServiceBuilder[IO]("/assets").toRoutes ``` For custom behaviour, `StaticFile.fromResource` can be used. In this example, only files matching a list of extensions are served. Append to the `List` as needed. ```scala mdoc:nest -def static(file: String, blocker: Blocker, request: Request[IO]) = - StaticFile.fromResource("/" + file, blocker, Some(request)).getOrElseF(NotFound()) +def static(file: String, request: Request[IO]) = + StaticFile.fromResource("/" + file, Some(request)).getOrElseF(NotFound()) val routes = HttpRoutes.of[IO] { case request @ GET -> Root / path if List(".js", ".css", ".map", ".html", ".webm").exists(path.endsWith) => - static(path, blocker, request) + static(path, request) } ``` @@ -140,7 +107,6 @@ libraryDependencies ++= Seq( Then, mount the `WebjarService` like any other service: ```scala mdoc:silent -import org.http4s.server.staticcontent.webjarService import org.http4s.server.staticcontent.WebjarServiceBuilder.WebjarAsset ``` @@ -149,11 +115,7 @@ import org.http4s.server.staticcontent.WebjarServiceBuilder.WebjarAsset def isJsAsset(asset: WebjarAsset): Boolean = asset.asset.endsWith(".js") -val webjars: HttpRoutes[IO] = webjarServiceBuilder[IO](blocker = blocker).withWebjarAssetFilter(isJsAsset).toRoutes -``` - -```scala mdoc:silent -blockingPool.shutdown() +val webjars: HttpRoutes[IO] = webjarServiceBuilder[IO].withWebjarAssetFilter(isJsAsset).toRoutes ``` Assuming that the service is mounted as root on port `8080`, and you included the webjar `swagger-ui-3.20.9.jar` on your classpath, you would reach the assets with the path: `http://localhost:8080/swagger-ui/3.20.9/index.html` diff --git a/docs/src/main/mdoc/streaming.md b/docs/src/main/mdoc/streaming.md index 969bf775a34..ebb64ac56eb 100644 --- a/docs/src/main/mdoc/streaming.md +++ b/docs/src/main/mdoc/streaming.md @@ -17,17 +17,12 @@ simplicity itself: ```scala mdoc:silent import scala.concurrent.duration._ -import scala.concurrent.ExecutionContext.Implicits.global import cats.effect._ import fs2.Stream import org.http4s._ import org.http4s.dsl.io._ -// Provided by `cats.effect.IOApp`, needed elsewhere: -implicit val timer: Timer[IO] = IO.timer(global) -implicit val cs: ContextShift[IO] = IO.contextShift(global) - // An infinite stream of the periodic elapsed time val seconds = Stream.awakeEvery[IO](1.second) @@ -90,10 +85,10 @@ import fs2.Stream import fs2.io.stdout import fs2.text.{lines, utf8Encode} import io.circe.Json -import jawnfs2._ +import org.typelevel.jawn.fs2._ import scala.concurrent.ExecutionContext.global -class TWStream[F[_]: ConcurrentEffect : ContextShift] { +class TWStream[F[_]: Async] { // jawn-fs2 needs to know what JSON AST you want implicit val f = new io.circe.jawn.CirceSupportParser(None, false).facade @@ -124,17 +119,15 @@ class TWStream[F[_]: ConcurrentEffect : ContextShift] { * We map over the Circe `Json` objects to pretty-print them with `spaces2`. * Then we `to` them to fs2's `lines` and then to `stdout` `Sink` to print them. */ - def stream(blocker: Blocker): Stream[F, Unit] = { + val stream: Stream[F, Unit] = { val req = Request[F](Method.GET, uri"https://stream.twitter.com/1.1/statuses/sample.json") val s = jsonStream("", "", "", "")(req) - s.map(_.spaces2).through(lines).through(utf8Encode).through(stdout(blocker)) + s.map(_.spaces2).through(lines).through(utf8Encode).through(stdout) } /** Compile our stream down to an effect to make it runnable */ def run: F[Unit] = - Stream.resource(Blocker[F]).flatMap { blocker => - stream(blocker) - }.compile.drain + stream.compile.drain } ``` @@ -155,6 +148,6 @@ object TWStreamApp extends IOApp { [ScalaSyd 2015]: https://bitbucket.org/da_terry/scalasyd-doobie-http4s [json]: ../json [jawn]: https://github.com/non/jawn -[jawn-fs2]: https://github.com/rossabaker/jawn-fs2 +[jawn-fs2]: https://github.com/typelevel/jawn-fs2 [Twitter's streaming APIs]: https://dev.twitter.com/streaming/overview [circe]: https://circe.github.io/circe/ diff --git a/docs/src/main/mdoc/testing.md b/docs/src/main/mdoc/testing.md index 509d2fc61b4..0a1c978b909 100644 --- a/docs/src/main/mdoc/testing.md +++ b/docs/src/main/mdoc/testing.md @@ -25,6 +25,13 @@ import org.http4s.dsl.io._ import org.http4s.implicits._ ``` +If you're in a REPL, we also need a runtime: + +```scala mdoc:silent:nest +import cats.effect.unsafe.IORuntime +implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global +``` + ```scala mdoc case class User(name: String, age: Int) implicit val UserEncoder: Encoder[User] = deriveEncoder[User] @@ -34,7 +41,7 @@ trait UserRepo[F[_]] { } def service[F[_]](repo: UserRepo[F])( - implicit F: Effect[F] + implicit F: Async[F] ): HttpRoutes[F] = HttpRoutes.of[F] { case GET -> Root / "user" / id => repo.find(id).map { @@ -76,8 +83,8 @@ val response: IO[Response[IO]] = service[IO](success).orNotFound.run( ) val expectedJson = Json.obj( - ("name", Json.fromString("johndoe")), - ("age", Json.fromBigInt(42)) + "name" := "johndoe", + "age" := 42 ) check[Json](response, Status.Ok, Some(expectedJson)) @@ -111,6 +118,29 @@ val response: IO[Response[IO]] = service[IO](doesNotMatter).orNotFound.run( check[String](response, Status.NotFound, Some("Not found")) ``` +### Using client + +Having HttpApp you can build a client for testing purposes. Following the example above we could define our HttpApp like this: + +```scala mdoc:nest +val httpApp: HttpApp[IO] = service[IO](success).orNotFound +``` + +From this, we can obtain the `Client` instance using `Client.fromHttpApp` and then use it to test our sever/app. + +```scala mdoc:nest +import org.http4s.client.Client + +val request: Request[IO] = Request(method = Method.GET, uri = uri"/user/not-used") +val expectedJson = Json.obj( + "name" := "johndoe", + "age" := 42 +) +val client: Client[IO] = Client.fromHttpApp(httpApp) +val resp: IO[Json] = client.expect[Json](request) +assert(resp.unsafeRunSync() == expectedJson) +``` + ## Conclusion The above documentation demonstrated how to define an HttpService[F], pass `Request`'s, and then diff --git a/dropwizard-metrics/src/test/scala/org/http4s/metrics/dropwizard/util.scala b/dropwizard-metrics/src/test/scala/org/http4s/metrics/dropwizard/util.scala index 11ea506f0aa..7496e1c7ec1 100644 --- a/dropwizard-metrics/src/test/scala/org/http4s/metrics/dropwizard/util.scala +++ b/dropwizard-metrics/src/test/scala/org/http4s/metrics/dropwizard/util.scala @@ -24,7 +24,7 @@ import java.util.concurrent.{TimeUnit, TimeoutException} import org.http4s.{Request, Response} import org.http4s.dsl.io._ import org.http4s.Method.GET -import scala.concurrent.duration.TimeUnit +import scala.concurrent.duration.FiniteDuration object util { def stub: PartialFunction[Request[IO], IO[Response[IO]]] = { @@ -62,16 +62,18 @@ object util { new Clock[F] { private var count = 0L - override def realTime(unit: TimeUnit): F[Long] = + override def applicative: cats.Applicative[F] = Sync[F] + + override def realTime: F[FiniteDuration] = Sync[F].delay { count += 50 - unit.convert(count, TimeUnit.MILLISECONDS) + FiniteDuration(count, TimeUnit.MILLISECONDS) } - override def monotonic(unit: TimeUnit): F[Long] = + override def monotonic: F[FiniteDuration] = Sync[F].delay { count += 50 - unit.convert(count, TimeUnit.MILLISECONDS) + FiniteDuration(count, TimeUnit.MILLISECONDS) } } } diff --git a/dsl/src/main/scala/org/http4s/dsl/impl/ResponseGenerator.scala b/dsl/src/main/scala/org/http4s/dsl/impl/ResponseGenerator.scala index 1aff12f1e9b..dd5afa79088 100644 --- a/dsl/src/main/scala/org/http4s/dsl/impl/ResponseGenerator.scala +++ b/dsl/src/main/scala/org/http4s/dsl/impl/ResponseGenerator.scala @@ -92,10 +92,6 @@ trait EntityResponseGenerator[F[_], G[_]] extends Any with ResponseGenerator { * distinguishes this from other `EntityResponseGenerator`s. */ trait LocationResponseGenerator[F[_], G[_]] extends Any with EntityResponseGenerator[F, G] { - @deprecated("Use `apply(Location(location))` instead", "0.18.0-M2") - def apply(uri: Uri)(implicit F: Applicative[F]): F[Response[G]] = - apply(Location(uri)) - def apply(location: Location)(implicit F: Applicative[F]): F[Response[G]] = F.pure(Response[G](status = status, headers = Headers(`Content-Length`.zero, location))) @@ -115,15 +111,6 @@ trait LocationResponseGenerator[F[_], G[_]] extends Any with EntityResponseGener * distinguishes this from other `ResponseGenerator`s. */ trait WwwAuthenticateResponseGenerator[F[_], G[_]] extends Any with ResponseGenerator { - @deprecated("Use ``apply(`WWW-Authenticate`(challenge, challenges)`` instead", "0.18.0-M2") - def apply(challenge: Challenge, challenges: Challenge*)(implicit - F: Applicative[F]): F[Response[G]] = - F.pure( - Response[G]( - status = status, - headers = Headers(`Content-Length`.zero, `WWW-Authenticate`(challenge, challenges: _*)) - )) - def apply(authenticate: `WWW-Authenticate`, headers: Header.ToRaw*)(implicit F: Applicative[F]): F[Response[G]] = F.pure( @@ -164,15 +151,6 @@ trait AllowResponseGenerator[F[_], G[_]] extends Any with ResponseGenerator { * distinguishes this from other `EntityResponseGenerator`s. */ trait ProxyAuthenticateResponseGenerator[F[_], G[_]] extends Any with ResponseGenerator { - @deprecated("Use ``apply(`Proxy-Authenticate`(challenge, challenges)`` instead", "0.18.0-M2") - def apply(challenge: Challenge, challenges: Challenge*)(implicit - F: Applicative[F]): F[Response[G]] = - F.pure( - Response[G]( - status = status, - headers = Headers(`Content-Length`.zero, `Proxy-Authenticate`(challenge, challenges: _*)) - )) - def apply(authenticate: `Proxy-Authenticate`, headers: Header.ToRaw*)(implicit F: Applicative[F]): F[Response[G]] = F.pure( diff --git a/ember-client/src/main/scala/org/http4s/ember/client/EmberClient.scala b/ember-client/src/main/scala/org/http4s/ember/client/EmberClient.scala index f49f4ccfe2c..602847feacf 100644 --- a/ember-client/src/main/scala/org/http4s/ember/client/EmberClient.scala +++ b/ember-client/src/main/scala/org/http4s/ember/client/EmberClient.scala @@ -24,7 +24,7 @@ import org.typelevel.keypool._ final class EmberClient[F[_]] private[client] ( private val client: Client[F], private val pool: KeyPool[F, RequestKey, EmberConnection[F]] -)(implicit F: BracketThrow[F]) +)(implicit F: MonadCancelThrow[F]) extends DefaultClient[F] { /** The reason for this extra class. This allows you to see the present state diff --git a/ember-client/src/main/scala/org/http4s/ember/client/EmberClientBuilder.scala b/ember-client/src/main/scala/org/http4s/ember/client/EmberClientBuilder.scala index 1a94885319e..c9f2a479c4e 100644 --- a/ember-client/src/main/scala/org/http4s/ember/client/EmberClientBuilder.scala +++ b/ember-client/src/main/scala/org/http4s/ember/client/EmberClientBuilder.scala @@ -16,9 +16,6 @@ package org.http4s.ember.client -import org.typelevel.keypool._ -import org.typelevel.log4cats.Logger -import org.typelevel.log4cats.slf4j.Slf4jLogger import cats._ import cats.syntax.all._ import cats.effect._ @@ -26,9 +23,13 @@ import cats.effect._ import scala.concurrent.duration._ import org.http4s.ProductId import org.http4s.client._ -import fs2.io.tcp.SocketGroup -import fs2.io.tcp.SocketOptionMapping -import fs2.io.tls._ +import org.typelevel.keypool._ +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.slf4j.Slf4jLogger +import fs2.io.net.SocketGroup +import fs2.io.net.SocketOption +import fs2.io.net.tls._ +import fs2.io.net.Network import scala.concurrent.duration.Duration import org.http4s.headers.{`User-Agent`} @@ -36,10 +37,9 @@ import org.http4s.ember.client.internal.ClientHelpers import org.http4s.client.middleware.RetryPolicy import org.http4s.client.middleware.Retry -final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( - private val blockerOpt: Option[Blocker], - private val tlsContextOpt: Option[TLSContext], - private val sgOpt: Option[SocketGroup], +final class EmberClientBuilder[F[_]: Async] private ( + private val tlsContextOpt: Option[TLSContext[F]], + private val sgOpt: Option[SocketGroup[F]], val maxTotal: Int, val maxPerKey: RequestKey => Int, val idleTimeInPool: Duration, @@ -48,16 +48,15 @@ final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( val maxResponseHeaderSize: Int, private val idleConnectionTime: Duration, val timeout: Duration, - val additionalSocketOptions: List[SocketOptionMapping[_]], + val additionalSocketOptions: List[SocketOption], val userAgent: Option[`User-Agent`], val checkEndpointIdentification: Boolean, val retryPolicy: RetryPolicy[F] ) { self => private def copy( - blockerOpt: Option[Blocker] = self.blockerOpt, - tlsContextOpt: Option[TLSContext] = self.tlsContextOpt, - sgOpt: Option[SocketGroup] = self.sgOpt, + tlsContextOpt: Option[TLSContext[F]] = self.tlsContextOpt, + sgOpt: Option[SocketGroup[F]] = self.sgOpt, maxTotal: Int = self.maxTotal, maxPerKey: RequestKey => Int = self.maxPerKey, idleTimeInPool: Duration = self.idleTimeInPool, @@ -66,13 +65,12 @@ final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( maxResponseHeaderSize: Int = self.maxResponseHeaderSize, idleConnectionTime: Duration = self.idleConnectionTime, timeout: Duration = self.timeout, - additionalSocketOptions: List[SocketOptionMapping[_]] = self.additionalSocketOptions, + additionalSocketOptions: List[SocketOption] = self.additionalSocketOptions, userAgent: Option[`User-Agent`] = self.userAgent, checkEndpointIdentification: Boolean = self.checkEndpointIdentification, retryPolicy: RetryPolicy[F] = self.retryPolicy ): EmberClientBuilder[F] = new EmberClientBuilder[F]( - blockerOpt = blockerOpt, tlsContextOpt = tlsContextOpt, sgOpt = sgOpt, maxTotal = maxTotal, @@ -89,14 +87,11 @@ final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( retryPolicy = retryPolicy ) - def withTLSContext(tlsContext: TLSContext) = + def withTLSContext(tlsContext: TLSContext[F]) = copy(tlsContextOpt = tlsContext.some) def withoutTLSContext = copy(tlsContextOpt = None) - def withBlocker(blocker: Blocker) = - copy(blockerOpt = blocker.some) - - def withSocketGroup(sg: SocketGroup) = copy(sgOpt = sg.some) + def withSocketGroup(sg: SocketGroup[F]) = copy(sgOpt = sg.some) def withMaxTotal(maxTotal: Int) = copy(maxTotal = maxTotal) def withMaxPerKey(maxPerKey: RequestKey => Int) = copy(maxPerKey = maxPerKey) @@ -110,7 +105,7 @@ final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( copy(maxResponseHeaderSize = maxResponseHeaderSize) def withTimeout(timeout: Duration) = copy(timeout = timeout) - def withAdditionalSocketOptions(additionalSocketOptions: List[SocketOptionMapping[_]]) = + def withAdditionalSocketOptions(additionalSocketOptions: List[SocketOption]) = copy(additionalSocketOptions = additionalSocketOptions) def withUserAgent(userAgent: `User-Agent`) = @@ -128,14 +123,11 @@ final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( def build: Resource[F, Client[F]] = for { - blocker <- blockerOpt.fold(Blocker[F])(_.pure[Resource[F, *]]) - sg <- sgOpt.fold(SocketGroup[F](blocker))(_.pure[Resource[F, *]]) + sg <- Resource.pure(sgOpt.getOrElse(Network[F])) tlsContextOptWithDefault <- Resource.eval( - tlsContextOpt - .fold(TLSContext.system(blocker).attempt.map(_.toOption))(_.some.pure[F]) - ) + tlsContextOpt.fold(Network[F].tlsContext.system.attempt.map(_.toOption))(_.some.pure[F])) builder = - KeyPoolBuilder + KeyPool.Builder .apply[F, RequestKey, EmberConnection[F]]( (requestKey: RequestKey) => EmberConnection( @@ -147,11 +139,10 @@ final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( sg, additionalSocketOptions )) <* logger.trace(s"Created Connection - RequestKey: ${requestKey}"), - { case connection => + (connection: EmberConnection[F]) => logger.trace( s"Shutting Down Connection - RequestKey: ${connection.keySocket.requestKey}") >> connection.cleanup - } ) .withDefaultReuseState(Reusable.DontReuse) .withIdleTimeAllowedInPool(idleTimeInPool) @@ -183,7 +174,7 @@ final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( ) ) { case ((response, drain), exitCase) => exitCase match { - case ExitCase.Completed => + case Resource.ExitCase.Succeeded => ClientHelpers.postProcessResponse( request, response, @@ -202,9 +193,8 @@ final class EmberClientBuilder[F[_]: Concurrent: Timer: ContextShift] private ( object EmberClientBuilder { - def default[F[_]: Concurrent: Timer: ContextShift] = + def default[F[_]: Async] = new EmberClientBuilder[F]( - blockerOpt = None, tlsContextOpt = None, sgOpt = None, maxTotal = Defaults.maxTotal, @@ -234,7 +224,7 @@ object EmberClientBuilder { } val maxTotal = 100 val idleTimeInPool = 30.seconds // 30 Seconds in Nanos - val additionalSocketOptions = List.empty[SocketOptionMapping[_]] + val additionalSocketOptions = List.empty[SocketOption] val userAgent = Some( `User-Agent`(ProductId("http4s-ember", Some(org.http4s.BuildInfo.version)))) diff --git a/ember-client/src/main/scala/org/http4s/ember/client/EmberConnection.scala b/ember-client/src/main/scala/org/http4s/ember/client/EmberConnection.scala index f3c4ff91058..36f4cc1e67a 100644 --- a/ember-client/src/main/scala/org/http4s/ember/client/EmberConnection.scala +++ b/ember-client/src/main/scala/org/http4s/ember/client/EmberConnection.scala @@ -19,7 +19,7 @@ package org.http4s.ember.client import cats._ import cats.effect.{Concurrent, Resource} import cats.syntax.all._ -import cats.effect.concurrent.Ref +import cats.effect.kernel.Ref private[ember] final case class EmberConnection[F[_]]( keySocket: RequestKeySocket[F], @@ -29,7 +29,6 @@ private[ember] final case class EmberConnection[F[_]]( nextBytes.set(Array.emptyByteArray) >> keySocket.socket.endOfInput.attempt.void >> keySocket.socket.endOfOutput.attempt.void >> - keySocket.socket.close.attempt.void >> shutdown.attempt.void } diff --git a/ember-client/src/main/scala/org/http4s/ember/client/RequestKeySocket.scala b/ember-client/src/main/scala/org/http4s/ember/client/RequestKeySocket.scala index 32423f2ad23..42c68e04970 100644 --- a/ember-client/src/main/scala/org/http4s/ember/client/RequestKeySocket.scala +++ b/ember-client/src/main/scala/org/http4s/ember/client/RequestKeySocket.scala @@ -16,7 +16,7 @@ package org.http4s.ember.client -import fs2.io.tcp._ +import fs2.io.net._ import org.http4s.client.RequestKey private[client] final case class RequestKeySocket[F[_]]( diff --git a/ember-client/src/main/scala/org/http4s/ember/client/internal/ClientHelpers.scala b/ember-client/src/main/scala/org/http4s/ember/client/internal/ClientHelpers.scala index 9daf32574e3..a9f3d1ea61c 100644 --- a/ember-client/src/main/scala/org/http4s/ember/client/internal/ClientHelpers.scala +++ b/ember-client/src/main/scala/org/http4s/ember/client/internal/ClientHelpers.scala @@ -17,37 +17,36 @@ package org.http4s.ember.client.internal import org.http4s.ember.client._ -import fs2.io.tcp._ +import fs2.io.net._ import cats._ import cats.data.NonEmptyList -import cats.effect._ -import cats.effect.implicits._ -import cats.effect.concurrent._ +import cats.effect.kernel.{Async, Clock, Concurrent, Ref, Resource, Sync} import cats.syntax.all._ + import scala.concurrent.duration._ -import java.net.InetSocketAddress import org.http4s._ import org.http4s.client.RequestKey import org.http4s.client.middleware._ import org.http4s.ember.core.EmberException import org.typelevel.ci._ import _root_.org.http4s.ember.core.{Encoder, Parser} -import _root_.fs2.io.tcp.SocketGroup -import _root_.fs2.io.tls._ +import _root_.fs2.io.net.SocketGroup +import _root_.fs2.io.net.tls._ import org.typelevel.keypool._ + import javax.net.ssl.SNIHostName import org.http4s.headers.{Connection, Date, `Idempotency-Key`, `User-Agent`} import _root_.org.http4s.ember.core.Util._ +import com.comcast.ip4s.{Host, Hostname, IDN, IpAddress, Port, SocketAddress} import java.nio.channels.ClosedChannelException private[client] object ClientHelpers { - - def requestToSocketWithKey[F[_]: Concurrent: ContextShift]( + def requestToSocketWithKey[F[_]: Sync]( request: Request[F], - tlsContextOpt: Option[TLSContext], + tlsContextOpt: Option[TLSContext[F]], enableEndpointValidation: Boolean, - sg: SocketGroup, - additionalSocketOptions: List[SocketOptionMapping[_]] + sg: SocketGroup[F], + additionalSocketOptions: List[SocketOption] ): Resource[F, RequestKeySocket[F]] = { val requestKey = RequestKey.fromRequest(request) requestKeyToSocketWithKey[F]( @@ -59,16 +58,16 @@ private[client] object ClientHelpers { ) } - def requestKeyToSocketWithKey[F[_]: Concurrent: ContextShift]( + def requestKeyToSocketWithKey[F[_]: Sync]( requestKey: RequestKey, - tlsContextOpt: Option[TLSContext], + tlsContextOpt: Option[TLSContext[F]], enableEndpointValidation: Boolean, - sg: SocketGroup, - additionalSocketOptions: List[SocketOptionMapping[_]] + sg: SocketGroup[F], + additionalSocketOptions: List[SocketOption] ): Resource[F, RequestKeySocket[F]] = for { address <- Resource.eval(getAddress(requestKey)) - initSocket <- sg.client[F](address, additionalSocketOptions = additionalSocketOptions) + initSocket <- sg.client(address, options = additionalSocketOptions) socket <- { if (requestKey.scheme === Uri.Scheme.https) tlsContextOpt.fold[Resource[F, Socket[F]]] { @@ -77,20 +76,26 @@ private[client] object ClientHelpers { ) } { tlsContext => tlsContext - .client( - initSocket, + .clientBuilder(initSocket) + .withParameters( TLSParameters( - serverNames = Some(List(new SNIHostName(address.getHostName))), + serverNames = extractHostname(address.host).map(List(_)), endpointIdentificationAlgorithm = - if (enableEndpointValidation) Some("HTTPS") else None) - ) + if (enableEndpointValidation) Some("HTTPS") else None)) + .build .widen[Socket[F]] } else initSocket.pure[Resource[F, *]] } } yield RequestKeySocket(socket, requestKey) - def request[F[_]: Concurrent: Timer]( + private def extractHostname(from: Host): Option[SNIHostName] = from match { + case hostname: Hostname => new SNIHostName(hostname.normalized.toString).some + case address: IpAddress => new SNIHostName(address.toString).some + case idn: IDN => extractHostname(idn.hostname) + } + + def request[F[_]: Async]( request: Request[F], connection: EmberConnection[F], chunkSize: Int, @@ -100,30 +105,25 @@ private[client] object ClientHelpers { userAgent: Option[`User-Agent`] ): F[(Response[F], F[Option[Array[Byte]]])] = { - def writeRequestToSocket( - req: Request[F], - socket: Socket[F], - timeout: Option[FiniteDuration]): F[Unit] = + def writeRequestToSocket(req: Request[F], socket: Socket[F]): F[Unit] = Encoder .reqToBytes(req) - .through(socket.writes(timeout)) + .through(_.chunks.foreach(c => timeoutMaybe(socket.write(c), idleTimeout))) .compile .drain def writeRead(req: Request[F]): F[(Response[F], F[Option[Array[Byte]]])] = - writeRequestToSocket(req, connection.keySocket.socket, durationToFinite(idleTimeout)) >> + writeRequestToSocket(req, connection.keySocket.socket) >> connection.nextBytes.getAndSet(Array.emptyByteArray).flatMap { head => - val finiteDuration = durationToFinite(timeout) val parse = Parser.Response.parser(maxResponseHeaderSize)( head, - connection.keySocket.socket.read(chunkSize, durationToFinite(idleTimeout)) + timeoutMaybe(connection.keySocket.socket.read(chunkSize), idleTimeout) ) - - finiteDuration.fold(parse)(duration => - parse.timeoutTo( - duration, - ApplicativeThrow[F].raiseError(new java.util.concurrent.TimeoutException( - s"Timed Out on EmberClient Header Receive Timeout: $duration")))) + timeoutToMaybe( + parse, + timeout, + ApplicativeThrow[F].raiseError(new java.util.concurrent.TimeoutException( + s"Timed Out on EmberClient Header Receive Timeout: $timeout"))) } for { @@ -170,12 +170,14 @@ private[client] object ClientHelpers { } // https://github.com/http4s/http4s/blob/main/blaze-client/src/main/scala/org/http4s/client/blaze/Http1Support.scala#L86 - private def getAddress[F[_]: Sync](requestKey: RequestKey): F[InetSocketAddress] = + private def getAddress[F[_]: Sync](requestKey: RequestKey): F[SocketAddress[Host]] = requestKey match { case RequestKey(s, auth) => val port = auth.port.getOrElse(if (s == Uri.Scheme.https) 443 else 80) val host = auth.host.value - Sync[F].delay(new InetSocketAddress(host, port)) + Sync[F].delay( + SocketAddress[Host](Host.fromString(host).get, Port.fromInt(port).get) + ) // FIXME } // Assumes that the request doesn't have fancy finalizers besides shutting down the pool diff --git a/ember-client/src/test/scala/org/http4s/ember/client/internal/ClientHelpersSuite.scala b/ember-client/src/test/scala/org/http4s/ember/client/internal/ClientHelpersSuite.scala index 7555170f574..4779edcf715 100644 --- a/ember-client/src/test/scala/org/http4s/ember/client/internal/ClientHelpersSuite.scala +++ b/ember-client/src/test/scala/org/http4s/ember/client/internal/ClientHelpersSuite.scala @@ -18,7 +18,6 @@ package org.http4s.ember.client.internal import cats.data.NonEmptyList import cats.effect._ -import cats.effect.concurrent._ import org.http4s._ import org.http4s.headers.{Connection, Date, `User-Agent`} import org.http4s.ember.client.EmberClientBuilder diff --git a/ember-core/src/main/scala/org/http4s/ember/core/ChunkedEncoding.scala b/ember-core/src/main/scala/org/http4s/ember/core/ChunkedEncoding.scala index d971bd24183..cc4ed52772f 100644 --- a/ember-core/src/main/scala/org/http4s/ember/core/ChunkedEncoding.scala +++ b/ember-core/src/main/scala/org/http4s/ember/core/ChunkedEncoding.scala @@ -13,7 +13,7 @@ package ember.core import cats._ import cats.syntax.all._ -import cats.effect.concurrent.{Deferred, Ref} +import cats.effect.kernel.{Deferred, Ref} import fs2._ import scodec.bits.ByteVector import Shared._ @@ -36,7 +36,9 @@ private[ember] object ChunkedEncoding { // on left reading the header of chunk (acting as buffer) // on right reading the chunk itself, and storing remaining bytes of the chunk def go(expect: Either[ByteVector, Long], head: Array[Byte]): Pull[F, Byte, Unit] = { - val nextChunk = if (head.nonEmpty) Pull.pure(Some(Chunk.bytes(head))) else Pull.eval(read) + val nextChunk = + if (head.nonEmpty) Pull.pure(Some(Chunk.byteVector(ByteVector.apply(head)))) + else Pull.eval(read) nextChunk.flatMap { case None => Pull.raiseError(EmberException.ReachedEndOfStream()) @@ -76,16 +78,16 @@ private[ember] object ChunkedEncoding { case Right(remains) => if (remains == bv.size) - Pull.output(Chunk.ByteVectorChunk(bv)) >> go( + Pull.output(Chunk.byteVector(bv)) >> go( Left(ByteVector.empty), Array.emptyByteArray) else if (remains > bv.size) - Pull.output(Chunk.ByteVectorChunk(bv)) >> go( + Pull.output(Chunk.byteVector(bv)) >> go( Right(remains - bv.size), Array.emptyByteArray) else { val (out, next) = bv.splitAt(remains.toLong) - Pull.output(Chunk.ByteVectorChunk(out)) >> go(Left(ByteVector.empty), next.toArray) + Pull.output(Chunk.byteVector(out)) >> go(Left(ByteVector.empty), next.toArray) } } } @@ -116,7 +118,7 @@ private[ember] object ChunkedEncoding { } private val lastChunk: Chunk[Byte] = - Chunk.ByteVectorChunk((ByteVector('0') ++ `\r\n` ++ `\r\n`).compact) + Chunk.byteVector((ByteVector('0') ++ `\r\n` ++ `\r\n`).compact) /** Encodes chunk of bytes to http chunked encoding. */ @@ -124,7 +126,7 @@ private[ember] object ChunkedEncoding { def encodeChunk(bv: ByteVector): Chunk[Byte] = if (bv.isEmpty) Chunk.empty else - Chunk.ByteVectorChunk( + Chunk.byteVector( ByteVector.view(bv.size.toHexString.toUpperCase.getBytes) ++ `\r\n` ++ bv ++ `\r\n`) _.mapChunks { ch => encodeChunk(ch.toByteVector) diff --git a/ember-core/src/main/scala/org/http4s/ember/core/Parser.scala b/ember-core/src/main/scala/org/http4s/ember/core/Parser.scala index 877e9f7cbc2..918f1b92666 100644 --- a/ember-core/src/main/scala/org/http4s/ember/core/Parser.scala +++ b/ember-core/src/main/scala/org/http4s/ember/core/Parser.scala @@ -15,16 +15,15 @@ */ package org.http4s.ember.core - import cats._ -import cats.effect.{MonadThrow => _, _} -import cats.effect.concurrent.{Deferred, Ref} +import cats.effect.kernel.{Concurrent, Deferred, Ref} import cats.syntax.all._ import fs2._ import org.http4s._ import org.typelevel.ci.CIString import scala.annotation.switch import scala.collection.mutable +import scodec.bits.ByteVector import cats.data.EitherT private[ember] object Parser { @@ -402,7 +401,9 @@ private[ember] object Parser { if (contentLength > 0) { if (buffer.length >= contentLength) { val (body, rest) = buffer.splitAt(contentLength.toInt) - (Stream.chunk(Chunk.bytes(body)).covary[F], (Some(rest): Option[Array[Byte]]).pure[F]) + ( + Stream.chunk(Chunk.byteVector(ByteVector(body))).covary[F], + (Some(rest): Option[Array[Byte]]).pure[F]) .pure[F] } else { val unread = contentLength - buffer.length @@ -444,7 +445,7 @@ private[ember] object Parser { case Some(_) => Pull.raiseError(BodyAlreadyConsumedError()) case None => - Pull.output(Chunk.bytes(buffer)) >> go(unread) + Pull.output(Chunk.array(buffer)) >> go(unread) } pull.stream diff --git a/ember-core/src/main/scala/org/http4s/ember/core/Util.scala b/ember-core/src/main/scala/org/http4s/ember/core/Util.scala index 134498a2aff..b14347bf1c5 100644 --- a/ember-core/src/main/scala/org/http4s/ember/core/Util.scala +++ b/ember-core/src/main/scala/org/http4s/ember/core/Util.scala @@ -14,16 +14,20 @@ * limitations under the License. */ -package org.http4s.ember.core +package org.http4s +package ember.core -import scala.concurrent.duration._ -import org.http4s.HttpVersion -import org.http4s.Headers -import org.http4s.Header -import org.http4s.headers.Connection -import org.typelevel.ci._ +import cats._ import cats.data.NonEmptyList +import cats.effect.kernel.{Clock, Temporal} +import cats.syntax.all._ +import fs2._ +import fs2.io.net.Socket import java.util.Locale +import org.http4s.headers.Connection +import org.typelevel.ci._ +import scala.concurrent.duration._ +import java.time.Instant private[ember] object Util { @@ -33,11 +37,77 @@ private[ember] object Util { private[this] val close = Connection(NonEmptyList.of(closeCi)) private[this] val keepAlive = Connection(NonEmptyList.one(keepAliveCi)) + private def streamCurrentTimeMillis[F[_]](clock: Clock[F]): Stream[F, Long] = + Stream + .eval(clock.realTime) + .map(_.toMillis) + + /** The issue with a normal http body is that there is no termination character, + * thus unless you have content-length and the client still has their input side open, + * the server cannot know whether more data follows or not + * This means this Stream MUST be infinite and additional parsing is required. + * To know how much client input to consume + * + * Function if timeout reads via socket read and then incrementally lowers + * the remaining time after each read. + * By setting the timeout signal outside this after the + * headers have been read it triggers this function + * to then not timeout on the remaining body. + */ + def readWithTimeout[F[_]]( + socket: Socket[F], + started: Long, + timeout: FiniteDuration, + shallTimeout: F[Boolean], + chunkSize: Int + )(implicit F: ApplicativeThrow[F], C: Clock[F]): Stream[F, Byte] = { + def whenWontTimeout: Stream[F, Byte] = + socket.reads + def whenMayTimeout(remains: FiniteDuration): Stream[F, Byte] = + if (remains <= 0.millis) + streamCurrentTimeMillis(C) + .flatMap(now => + Stream.raiseError[F]( + EmberException.Timeout(Instant.ofEpochMilli(started), Instant.ofEpochMilli(now)) + )) + else + for { + start <- streamCurrentTimeMillis(C) + read <- Stream.eval(socket.read(chunkSize)) // Each Read Yields + end <- streamCurrentTimeMillis(C) + out <- read.fold[Stream[F, Byte]]( + Stream.empty + )( + Stream.chunk(_).covary[F] ++ go(remains - (end - start).millis) + ) + } yield out + def go(remains: FiniteDuration): Stream[F, Byte] = + Stream + .eval(shallTimeout) + .ifM( + whenMayTimeout(remains), + whenWontTimeout + ) + go(timeout) + } + def durationToFinite(duration: Duration): Option[FiniteDuration] = duration match { case f: FiniteDuration => Some(f) case _ => None } + def timeoutMaybe[F[_], A](fa: F[A], d: Duration)(implicit F: Temporal[F]): F[A] = + d match { + case fd: FiniteDuration => F.timeout(fa, fd) + case _ => fa + } + + def timeoutToMaybe[F[_], A](fa: F[A], d: Duration, ft: F[A])(implicit F: Temporal[F]): F[A] = + d match { + case fd: FiniteDuration => F.timeoutTo(fa, fd, ft) + case _ => fa + } + def connectionFor(httpVersion: HttpVersion, headers: Headers): Connection = if (isKeepAlive(httpVersion, headers)) keepAlive else close diff --git a/ember-core/src/test/scala/org/http4s/ember/core/EncoderSuite.scala b/ember-core/src/test/scala/org/http4s/ember/core/EncoderSuite.scala index 3f35da5d1ed..d7f877c1121 100644 --- a/ember-core/src/test/scala/org/http4s/ember/core/EncoderSuite.scala +++ b/ember-core/src/test/scala/org/http4s/ember/core/EncoderSuite.scala @@ -29,7 +29,7 @@ class EncoderSuite extends Http4sSuite { def encodeRequestRig[F[_]: Sync](req: Request[F]): F[String] = Encoder .reqToBytes(req) - .through(fs2.text.utf8Decode[F]) + .through(fs2.text.utf8.decode[F]) .compile .string .map(stripLines) @@ -38,7 +38,7 @@ class EncoderSuite extends Http4sSuite { def encodeResponseRig[F[_]: Sync](resp: Response[F]): F[String] = Encoder .respToBytes(resp) - .through(fs2.text.utf8Decode[F]) + .through(fs2.text.utf8.decode[F]) .compile .string .map(stripLines) diff --git a/ember-core/src/test/scala/org/http4s/ember/core/ParserSuite.scala b/ember-core/src/test/scala/org/http4s/ember/core/ParserSuite.scala index 536584b460d..6de0291c29e 100644 --- a/ember-core/src/test/scala/org/http4s/ember/core/ParserSuite.scala +++ b/ember-core/src/test/scala/org/http4s/ember/core/ParserSuite.scala @@ -16,16 +16,14 @@ package org.http4s.ember.core -import cats.effect._ -import fs2.concurrent.Queue +import cats.effect.std.Queue import org.http4s._ import org.http4s.implicits._ import scodec.bits.ByteVector import fs2._ -import cats.effect.concurrent._ +import cats.effect._ import cats.data.OptionT import cats.syntax.all._ -import fs2.Chunk.ByteVectorChunk import org.http4s.headers.Expires import org.typelevel.ci._ @@ -37,9 +35,9 @@ class ParsingSuite extends Http4sSuite { def taking[F[_]: Concurrent, A](stream: Stream[F, A]): F[F[Option[Chunk[A]]]] = for { q <- Queue.unbounded[F, Option[Chunk[A]]] - _ <- stream.chunks.map(Some(_)).evalMap(q.enqueue1(_)).compile.drain.void - _ <- q.enqueue1(None) - } yield q.dequeue1 + _ <- stream.chunks.map(Some(_)).evalMap(q.offer(_)).compile.drain + _ <- q.offer(None) + } yield q.take // Only for Use with Text Requests def parseRequestRig[F[_]: Concurrent](s: String): F[Request[F]] = { @@ -47,7 +45,7 @@ class ParsingSuite extends Http4sSuite { .emit(s) .covary[F] .map(httpifyString) - .through(fs2.text.utf8Encode[F]) + .through(fs2.text.utf8.encode[F]) taking(byteStream).flatMap { read => Parser.Request.parser[F](Int.MaxValue)(Array.emptyByteArray, read).map(_._1) @@ -59,7 +57,7 @@ class ParsingSuite extends Http4sSuite { .emit(s) .covary[F] .map(httpifyString) - .through(fs2.text.utf8Encode[F]) + .through(fs2.text.utf8.encode[F]) Resource.eval( taking(byteStream).flatMap { read => @@ -68,7 +66,7 @@ class ParsingSuite extends Http4sSuite { ) } - def forceScopedParsing[F[_]: Sync](s: String): Stream[F, Byte] = { + def forceScopedParsing[F[_]: Concurrent](s: String): Stream[F, Byte] = { val pivotPoint = s.trim().length - 1 val firstChunk = s.substring(0, pivotPoint).replace("\n", "\r\n") val secondChunk = s.substring(pivotPoint, s.length).replace("\n", "\r\n") @@ -148,8 +146,8 @@ class ParsingSuite extends Http4sSuite { _ <- result.map(_.uri.fragment).assertEquals(expected.uri.fragment) _ <- result.map(_.headers).assertEquals(expected.headers) r <- result - a <- r.body.through(fs2.text.utf8Decode).compile.string - b <- expected.body.through(fs2.text.utf8Decode).compile.string + a <- r.body.through(fs2.text.utf8.decode).compile.string + b <- expected.body.through(fs2.text.utf8.decode).compile.string } yield assertEquals(a, b) } @@ -184,7 +182,7 @@ class ParsingSuite extends Http4sSuite { val http1 = Helpers.httpifyString(raw1) val http2 = Helpers.httpifyString(raw2) - val encoded = (Stream(http1) ++ Stream(http2)).through(fs2.text.utf8Encode) + val encoded = (Stream(http1) ++ Stream(http2)).through(fs2.text.utf8.encode) (for { take <- Helpers.taking[IO, Byte](encoded) @@ -197,7 +195,7 @@ class ParsingSuite extends Http4sSuite { // I don't follow what the rig is testing vs this. ) //(logger) .flatMap { case (resp, _) => - resp.body.through(text.utf8Decode).compile.string + resp.body.through(text.utf8.decode).compile.string } } yield parsed == "{}").assert } @@ -217,16 +215,16 @@ class ParsingSuite extends Http4sSuite { .emit(raw) .covary[IO] .map(Helpers.httpifyString) - .through(text.utf8Encode) + .through(text.utf8.encode) for { take <- Helpers.taking[IO, Byte](byteStream) result <- Parser.Request.parser[IO](Int.MaxValue)(Array.emptyByteArray, take) - body <- result._1.body.through(text.utf8Decode).compile.string + body <- result._1.body.through(text.utf8.decode).compile.string rest <- Stream .eval(result._2) - .flatMap(chunk => Stream.chunk(Chunk.bytes(chunk.get))) - .through(text.utf8Decode) + .flatMap(chunk => Stream.chunk(Chunk.byteVector(ByteVector(chunk.get)))) + .through(text.utf8.decode) .compile .string } yield { @@ -261,10 +259,10 @@ class ParsingSuite extends Http4sSuite { val baseBv = ByteVector.fromBase64(base).get (for { - take <- Helpers.taking[IO, Byte](Stream.chunk(ByteVectorChunk(baseBv))) + take <- Helpers.taking[IO, Byte](Stream.chunk(Chunk.byteVector(baseBv))) result <- Parser.Response .parser[IO](defaultMaxHeaderLength)(Array.emptyByteArray, take) - body <- result._1.body.through(text.utf8Decode).compile.string + body <- result._1.body.through(text.utf8.decode).compile.string } yield body.size > 0).assert } @@ -294,7 +292,7 @@ class ParsingSuite extends Http4sSuite { take <- Helpers.taking[IO, Byte](byteStream) resp <- Parser.Response .parser[IO](defaultMaxHeaderLength)(Array.emptyByteArray, take) - body <- resp._1.body.through(text.utf8Decode).compile.string + body <- resp._1.body.through(text.utf8.decode).compile.string } yield body == "MozillaDeveloperNetwork").assert } @@ -325,7 +323,7 @@ class ParsingSuite extends Http4sSuite { take <- Helpers.taking[IO, Byte](byteStream) result <- Parser.Response .parser[IO](defaultMaxHeaderLength)(Array.emptyByteArray, take) - body <- result._1.body.through(text.utf8Decode).compile.string + body <- result._1.body.through(text.utf8.decode).compile.string trailers <- result._1.trailerHeaders } yield body == "MozillaDeveloperNetwork" && trailers.get[Expires].isDefined).assert } @@ -343,17 +341,17 @@ class ParsingSuite extends Http4sSuite { .emit(raw) .covary[IO] .map(Helpers.httpifyString) - .through(text.utf8Encode) + .through(text.utf8.encode) for { take <- Helpers.taking[IO, Byte](byteStream) result <- Parser.Response .parser[IO](defaultMaxHeaderLength)(Array.emptyByteArray, take) - body <- result._1.body.through(text.utf8Decode).compile.string + body <- result._1.body.through(text.utf8.decode).compile.string rest <- Stream .eval(result._2) - .flatMap(chunk => Stream.chunk(Chunk.bytes(chunk.get))) - .through(text.utf8Decode) + .flatMap(chunk => Stream.chunk(Chunk.byteVector(ByteVector(chunk.get)))) + .through(text.utf8.decode) .compile .string } yield { @@ -466,12 +464,12 @@ class ParsingSuite extends Http4sSuite { take <- Helpers.taking[IO, Byte](byteStream) resp1 <- Parser.Response .parser[IO](defaultMaxHeaderLength)(Array.emptyByteArray, take) - body1 <- resp1._1.body.through(fs2.text.utf8Decode).compile.string + body1 <- resp1._1.body.through(fs2.text.utf8.decode).compile.string drained <- resp1._2 _ <- IO(println(new String(drained.get))) resp2 <- Parser.Response .parser[IO](defaultMaxHeaderLength)(drained.get, take) - body2 <- resp2._1.body.through(fs2.text.utf8Decode).compile.string + body2 <- resp2._1.body.through(fs2.text.utf8.decode).compile.string } yield body1 == "hello" && body2 == "world").assert } @@ -484,7 +482,7 @@ class ParsingSuite extends Http4sSuite { for { take <- Helpers.taking(byteStream) body <- Parser.Body.parseFixedBody(12L, Array.emptyByteArray, take) - bodyString <- body._1.through(fs2.text.utf8Decode).compile.string + bodyString <- body._1.through(fs2.text.utf8.decode).compile.string drained <- body._2 } yield { assertEquals(bodyString, "hello world!") @@ -501,7 +499,7 @@ class ParsingSuite extends Http4sSuite { for { take <- Helpers.taking(byteStream) body <- Parser.Body.parseFixedBody(12L, Array.emptyByteArray, take) - bodyString <- body._1.take(2).through(fs2.text.utf8Decode).compile.string + bodyString <- body._1.take(2).through(fs2.text.utf8.decode).compile.string drained <- body._2 } yield { assertEquals(bodyString, "he") @@ -521,7 +519,7 @@ class ParsingSuite extends Http4sSuite { 12L, "hello ".getBytes(java.nio.charset.StandardCharsets.US_ASCII), take) - bodyString <- body._1.through(fs2.text.utf8Decode).compile.string + bodyString <- body._1.through(fs2.text.utf8.decode).compile.string drained <- body._2 } yield { assertEquals(bodyString, "hello world!") @@ -558,7 +556,7 @@ class ParsingSuite extends Http4sSuite { .emit(raw) .covary[IO] .map(Helpers.httpifyString) - .through(text.utf8Encode) + .through(text.utf8.encode) Helpers.taking[IO, Byte](byteStream).flatMap { take => Parser.Request diff --git a/ember-core/src/test/scala/org/http4s/ember/core/StreamingParserSuite.scala b/ember-core/src/test/scala/org/http4s/ember/core/StreamingParserSuite.scala index 676f6fa26c9..73acf7f04fa 100644 --- a/ember-core/src/test/scala/org/http4s/ember/core/StreamingParserSuite.scala +++ b/ember-core/src/test/scala/org/http4s/ember/core/StreamingParserSuite.scala @@ -17,9 +17,9 @@ package org.http4s.ember.core import cats.effect._ +import cats.effect.std.Queue import cats.syntax.all._ import fs2._ -import fs2.concurrent.Queue import org.http4s.Http4sSuite import org.scalacheck.Gen import org.scalacheck.Prop._ @@ -31,9 +31,9 @@ class StreamingParserSuite extends Http4sSuite { def taking[F[_]: Concurrent, A](segments: List[List[A]]): F[F[Option[Chunk[A]]]] = for { q <- Queue.unbounded[F, Option[Chunk[A]]] - _ <- segments.traverse(bytes => q.enqueue1(Some(Chunk.seq(bytes)))) - _ <- q.enqueue1(None) - } yield q.dequeue1 + _ <- segments.traverse(bytes => q.offer(Some(Chunk.seq(bytes)))) + _ <- q.offer(None) + } yield q.take def subdivided[A](as: List[A], count: Int): Gen[List[List[A]]] = { def go(out: List[List[A]], remaining: Int): Gen[List[List[A]]] = diff --git a/ember-core/src/test/scala/org/http4s/ember/core/TraversalSpec.scala b/ember-core/src/test/scala/org/http4s/ember/core/TraversalSpec.scala index 20f56bada7b..ac5ae97d9c1 100644 --- a/ember-core/src/test/scala/org/http4s/ember/core/TraversalSpec.scala +++ b/ember-core/src/test/scala/org/http4s/ember/core/TraversalSpec.scala @@ -20,25 +20,22 @@ class TraversalSpecItsNotYouItsMe import org.scalacheck.effect.PropF import cats.syntax.all._ -import cats.effect.{Concurrent, ContextShift, IO} +import cats.effect.{Concurrent, IO} +import cats.effect.std.Queue import fs2._ -import fs2.concurrent.Queue import org.http4s._ -// import _root_.io.chrisdavenport.log4cats.testing.TestingLogger +// import _root_.org.typelevel.log4cats.testing.TestingLogger import org.http4s.laws.discipline.ArbitraryInstances._ -import scala.concurrent.ExecutionContext // FIXME Restore after #3935 is worked out class TraversalSpec extends Http4sSuite { - implicit val CS: ContextShift[IO] = IO.contextShift(ExecutionContext.global) - object Helpers { def taking[F[_]: Concurrent, A](stream: Stream[F, A]): F[F[Option[Chunk[A]]]] = for { q <- Queue.unbounded[F, Option[Chunk[A]]] - _ <- stream.chunks.map(Some(_)).evalMap(q.enqueue1(_)).compile.drain.void - _ <- q.enqueue1(None) - } yield q.dequeue1 + _ <- stream.chunks.map(Some(_)).evalMap(q.offer(_)).compile.drain + _ <- q.offer(None) + } yield q.take } test("Request Encoder/Parser should preserve existing headers".ignore) { @@ -93,7 +90,7 @@ class TraversalSpec extends Http4sSuite { read <- Helpers.taking[IO, Byte](Encoder.reqToBytes[IO](newReq)) end <- Parser.Request .parser[IO](Int.MaxValue)(Array.emptyByteArray, read) //(logger) - b <- end._1.body.through(fs2.text.utf8Decode).compile.foldMonoid + b <- end._1.body.through(fs2.text.utf8.decode).compile.foldMonoid } yield b res.assertEquals(s) diff --git a/ember-server/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala b/ember-server/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala index 05d6850ea5d..c0c2e8817ab 100644 --- a/ember-server/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala +++ b/ember-server/src/main/scala/org/http4s/ember/server/EmberServerBuilder.scala @@ -19,26 +19,24 @@ package org.http4s.ember.server import cats._ import cats.syntax.all._ import cats.effect._ -import cats.effect.concurrent._ -import fs2.io.tcp.SocketGroup -import fs2.io.tcp.SocketOptionMapping -import fs2.io.tls._ +import com.comcast.ip4s._ +import fs2.io.net.{Network, SocketGroup, SocketOption} +import fs2.io.net.tls._ import org.http4s._ import org.http4s.server.Server +import java.net.InetSocketAddress import scala.concurrent.duration._ -import java.net.InetSocketAddress import _root_.org.typelevel.log4cats.Logger import _root_.org.typelevel.log4cats.slf4j.Slf4jLogger import org.http4s.ember.server.internal.{ServerHelpers, Shutdown} -final class EmberServerBuilder[F[_]: Concurrent: Timer: ContextShift] private ( - val host: String, - val port: Int, +final class EmberServerBuilder[F[_]: Async] private ( + val host: Option[Host], + val port: Port, private val httpApp: HttpApp[F], - private val blockerOpt: Option[Blocker], - private val tlsInfoOpt: Option[(TLSContext, TLSParameters)], - private val sgOpt: Option[SocketGroup], + private val tlsInfoOpt: Option[(TLSContext[F], TLSParameters)], + private val sgOpt: Option[SocketGroup[F]], private val errorHandler: Throwable => F[Response[F]], private val onWriteFailure: (Option[Request[F]], Response[F], Throwable) => F[Unit], val maxConnections: Int, @@ -47,7 +45,7 @@ final class EmberServerBuilder[F[_]: Concurrent: Timer: ContextShift] private ( val requestHeaderReceiveTimeout: Duration, val idleTimeout: Duration, val shutdownTimeout: Duration, - val additionalSocketOptions: List[SocketOptionMapping[_]], + val additionalSocketOptions: List[SocketOption], private val logger: Logger[F] ) { self => @@ -55,12 +53,11 @@ final class EmberServerBuilder[F[_]: Concurrent: Timer: ContextShift] private ( val maxConcurrency: Int = maxConnections private def copy( - host: String = self.host, - port: Int = self.port, + host: Option[Host] = self.host, + port: Port = self.port, httpApp: HttpApp[F] = self.httpApp, - blockerOpt: Option[Blocker] = self.blockerOpt, - tlsInfoOpt: Option[(TLSContext, TLSParameters)] = self.tlsInfoOpt, - sgOpt: Option[SocketGroup] = self.sgOpt, + tlsInfoOpt: Option[(TLSContext[F], TLSParameters)] = self.tlsInfoOpt, + sgOpt: Option[SocketGroup[F]] = self.sgOpt, errorHandler: Throwable => F[Response[F]] = self.errorHandler, onWriteFailure: (Option[Request[F]], Response[F], Throwable) => F[Unit] = self.onWriteFailure, maxConnections: Int = self.maxConnections, @@ -69,14 +66,13 @@ final class EmberServerBuilder[F[_]: Concurrent: Timer: ContextShift] private ( requestHeaderReceiveTimeout: Duration = self.requestHeaderReceiveTimeout, idleTimeout: Duration = self.idleTimeout, shutdownTimeout: Duration = self.shutdownTimeout, - additionalSocketOptions: List[SocketOptionMapping[_]] = self.additionalSocketOptions, + additionalSocketOptions: List[SocketOption] = self.additionalSocketOptions, logger: Logger[F] = self.logger ): EmberServerBuilder[F] = new EmberServerBuilder[F]( host = host, port = port, httpApp = httpApp, - blockerOpt = blockerOpt, tlsInfoOpt = tlsInfoOpt, sgOpt = sgOpt, errorHandler = errorHandler, @@ -91,21 +87,21 @@ final class EmberServerBuilder[F[_]: Concurrent: Timer: ContextShift] private ( logger = logger ) - def withHost(host: String) = copy(host = host) - def withPort(port: Int) = copy(port = port) + def withHostOption(host: Option[Host]) = copy(host = host) + def withHost(host: Host) = withHostOption(Some(host)) + def withoutHost = withHostOption(None) + + def withPort(port: Port) = copy(port = port) def withHttpApp(httpApp: HttpApp[F]) = copy(httpApp = httpApp) - def withSocketGroup(sg: SocketGroup) = + def withSocketGroup(sg: SocketGroup[F]) = copy(sgOpt = sg.pure[Option]) - def withTLS(tlsContext: TLSContext, tlsParameters: TLSParameters = TLSParameters.Default) = + def withTLS(tlsContext: TLSContext[F], tlsParameters: TLSParameters = TLSParameters.Default) = copy(tlsInfoOpt = (tlsContext, tlsParameters).pure[Option]) def withoutTLS = copy(tlsInfoOpt = None) - def withBlocker(blocker: Blocker) = - copy(blockerOpt = blocker.pure[Option]) - def withIdleTimeout(idleTimeout: Duration) = copy(idleTimeout = idleTimeout) @@ -135,17 +131,17 @@ final class EmberServerBuilder[F[_]: Concurrent: Timer: ContextShift] private ( def build: Resource[F, Server] = for { - bindAddress <- Resource.eval(Sync[F].delay(new InetSocketAddress(host, port))) - blocker <- blockerOpt.fold(Blocker[F])(_.pure[Resource[F, *]]) - sg <- sgOpt.fold(SocketGroup[F](blocker))(_.pure[Resource[F, *]]) - ready <- Resource.eval(Deferred[F, Either[Throwable, Unit]]) + sg <- sgOpt.getOrElse(Network[F]).pure[Resource[F, *]] + ready <- Resource.eval(Deferred[F, Either[Throwable, InetSocketAddress]]) shutdown <- Resource.eval(Shutdown[F](shutdownTimeout)) _ <- Concurrent[F].background( ServerHelpers .server( - bindAddress, - httpApp, + host, + port, + additionalSocketOptions, sg, + httpApp, tlsInfoOpt, ready, shutdown, @@ -156,15 +152,14 @@ final class EmberServerBuilder[F[_]: Concurrent: Timer: ContextShift] private ( maxHeaderSize, requestHeaderReceiveTimeout, idleTimeout, - additionalSocketOptions, logger ) .compile .drain ) _ <- Resource.make(Applicative[F].unit)(_ => shutdown.await) - _ <- Resource.eval(ready.get.rethrow) - _ <- Resource.eval(logger.info(s"Ember-Server service bound to address: $bindAddress")) + bindAddress <- Resource.eval(ready.get.rethrow) + _ <- Resource.eval(logger.info(s"Ember-Server service bound to address: ${bindAddress}")) } yield new Server { def address: InetSocketAddress = bindAddress def isSecure: Boolean = tlsInfoOpt.isDefined @@ -172,12 +167,11 @@ final class EmberServerBuilder[F[_]: Concurrent: Timer: ContextShift] private ( } object EmberServerBuilder { - def default[F[_]: Concurrent: Timer: ContextShift]: EmberServerBuilder[F] = + def default[F[_]: Async]: EmberServerBuilder[F] = new EmberServerBuilder[F]( - host = Defaults.host, - port = Defaults.port, + host = Host.fromString(Defaults.host), + port = Port.fromInt(Defaults.port).get, httpApp = Defaults.httpApp[F], - blockerOpt = None, tlsInfoOpt = None, sgOpt = None, errorHandler = Defaults.errorHandler[F], @@ -221,6 +215,6 @@ object EmberServerBuilder { val requestHeaderReceiveTimeout: Duration = 5.seconds val idleTimeout: Duration = server.defaults.IdleTimeout val shutdownTimeout: Duration = server.defaults.ShutdownTimeout - val additionalSocketOptions = List.empty[SocketOptionMapping[_]] + val additionalSocketOptions = List.empty[SocketOption] } } diff --git a/ember-server/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala b/ember-server/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala index 6327d809aad..e4dbda5bd82 100644 --- a/ember-server/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala +++ b/ember-server/src/main/scala/org/http4s/ember/server/internal/ServerHelpers.scala @@ -18,37 +18,39 @@ package org.http4s.ember.server.internal import cats._ import cats.effect._ -import cats.effect.concurrent._ -import cats.effect.implicits._ +import cats.effect.kernel.Resource import cats.syntax.all._ -import com.comcast.ip4s.SocketAddress +import com.comcast.ip4s._ import fs2.Stream -import fs2.io.tcp._ -import fs2.io.tls._ -import java.net.InetSocketAddress +import fs2.io.net._ +import fs2.io.net.tls._ import org.http4s._ import org.http4s.ember.core.Util._ +import org.http4s.headers.Connection +import java.net.InetSocketAddress import org.http4s.ember.core.{Drain, EmberException, Encoder, Parser, Read} import org.http4s.headers.Date import org.http4s.internal.tls.{deduceKeyLength, getCertChain} import org.http4s.server.{SecureSession, ServerRequestKeys} import org.typelevel.log4cats.Logger import org.typelevel.vault.Vault + import scala.concurrent.duration._ import scodec.bits.ByteVector -import org.http4s.headers.Connection private[server] object ServerHelpers { private val serverFailure = Response(Status.InternalServerError).putHeaders(org.http4s.headers.`Content-Length`.zero) - def server[F[_]: ContextShift]( - bindAddress: InetSocketAddress, + def server[F[_]]( + host: Option[Host], + port: Port, + additionalSocketOptions: List[SocketOption], + sg: SocketGroup[F], httpApp: HttpApp[F], - sg: SocketGroup, - tlsInfoOpt: Option[(TLSContext, TLSParameters)], - ready: Deferred[F, Either[Throwable, Unit]], + tlsInfoOpt: Option[(TLSContext[F], TLSParameters)], + ready: Deferred[F, Either[Throwable, InetSocketAddress]], shutdown: Shutdown[F], // Defaults errorHandler: Throwable => F[Response[F]], @@ -58,25 +60,22 @@ private[server] object ServerHelpers { maxHeaderSize: Int, requestHeaderReceiveTimeout: Duration, idleTimeout: Duration, - additionalSocketOptions: List[SocketOptionMapping[_]] = List.empty, logger: Logger[F] - )(implicit F: Concurrent[F], T: Timer[F]): Stream[F, Nothing] = { - - val server: Stream[F, Resource[F, Socket[F]]] = + )(implicit F: Async[F]): Stream[F, Nothing] = { + val server: Stream[F, Socket[F]] = Stream - .resource( - sg.serverResource[F](bindAddress, additionalSocketOptions = additionalSocketOptions)) + .resource(sg.serverResource(host, Some(port), additionalSocketOptions)) .attempt - .evalTap(e => ready.complete(e.void)) + .evalTap(e => ready.complete(e.map(_._1.toInetSocketAddress))) .rethrow - .flatMap { case (_, clients) => clients } + .flatMap(_._2) val streams: Stream[F, Stream[F, Nothing]] = server .interruptWhen(shutdown.signal.attempt) .map { connect => val handler = shutdown.trackConnection >> Stream - .resource(connect.flatMap(upgradeSocket(_, tlsInfoOpt, logger))) + .resource(upgradeSocket(connect, tlsInfoOpt, logger)) .flatMap( runConnection( _, @@ -95,7 +94,10 @@ private[server] object ServerHelpers { } } - StreamForking.forking(streams, maxConnections) + streams.parJoin( + maxConnections + ) // TODO: replace with forking after we fix serverResource upstream + // StreamForking.forking(streams, maxConnections) } // private[internal] def reachedEndError[F[_]: Sync]( @@ -108,33 +110,36 @@ private[server] object ServerHelpers { // case Some(value) => Stream.chunk(value) // } - private[internal] def upgradeSocket[F[_]: Concurrent: ContextShift]( + private[internal] def upgradeSocket[F[_]: Monad]( socketInit: Socket[F], - tlsInfoOpt: Option[(TLSContext, TLSParameters)], + tlsInfoOpt: Option[(TLSContext[F], TLSParameters)], logger: Logger[F] ): Resource[F, Socket[F]] = tlsInfoOpt.fold(socketInit.pure[Resource[F, *]]) { case (context, params) => context - .server(socketInit, params, { (s: String) => logger.trace(s) }.some) + .serverBuilder(socketInit) + .withParameters(params) + .withLogging(s => logger.trace(s)) + .build .widen[Socket[F]] } - private[internal] def runApp[F[_]: Concurrent: Timer]( - buffer: Array[Byte], + private[internal] def runApp[F[_]]( + head: Array[Byte], read: Read[F], maxHeaderSize: Int, requestHeaderReceiveTimeout: Duration, httpApp: HttpApp[F], errorHandler: Throwable => F[Response[F]], - socket: Socket[F]): F[(Request[F], Response[F], Drain[F])] = { - val parse = Parser.Request.parser(maxHeaderSize)(buffer, read) - val parseWithHeaderTimeout = - durationToFinite(requestHeaderReceiveTimeout).fold(parse)(duration => - parse.timeoutTo( - duration, - ApplicativeThrow[F].raiseError( - new java.util.concurrent.TimeoutException( - s"Timed Out on EmberServer Header Receive Timeout: $duration")))) + socket: Socket[F])(implicit F: Temporal[F]): F[(Request[F], Response[F], Drain[F])] = { + + val parse = Parser.Request.parser(maxHeaderSize)(head, read) + val parseWithHeaderTimeout = timeoutToMaybe( + parse, + requestHeaderReceiveTimeout, + F.raiseError[(Request[F], F[Option[Array[Byte]]])](new java.util.concurrent.TimeoutException( + s"Timed Out on EmberServer Header Receive Timeout: $requestHeaderReceiveTimeout")) + ) for { tmp <- parseWithHeaderTimeout @@ -147,24 +152,23 @@ private[server] object ServerHelpers { } yield (req, resp, drain) } - private[internal] def send[F[_]: Sync](socket: Socket[F])( + private[internal] def send[F[_]: Temporal](socket: Socket[F])( request: Option[Request[F]], resp: Response[F], idleTimeout: Duration, onWriteFailure: (Option[Request[F]], Response[F], Throwable) => F[Unit]): F[Unit] = Encoder .respToBytes[F](resp) - .through(socket.writes(durationToFinite(idleTimeout))) + .through(_.chunks.foreach(c => timeoutMaybe(socket.write(c), idleTimeout))) .compile .drain .attempt .flatMap { - case Left(err) => - onWriteFailure(request, resp, err) - case Right(()) => Sync[F].pure(()) + case Left(err) => onWriteFailure(request, resp, err) + case Right(()) => Applicative[F].unit } - private[internal] def postProcessResponse[F[_]: Timer: Monad]( + private[internal] def postProcessResponse[F[_]: Concurrent: Clock]( req: Request[F], resp: Response[F]): F[Response[F]] = { val connection = connectionFor(req.httpVersion, req.headers) @@ -173,7 +177,7 @@ private[server] object ServerHelpers { } yield resp.withHeaders(Headers(date, connection) ++ resp.headers) } - private[internal] def runConnection[F[_]: Concurrent: Timer]( + private[internal] def runConnection[F[_]: Async]( socket: Socket[F], logger: Logger[F], idleTimeout: Duration, @@ -186,7 +190,7 @@ private[server] object ServerHelpers { ): Stream[F, Nothing] = { type State = (Array[Byte], Boolean) val _ = logger - val read: Read[F] = socket.read(receiveBufferSize, durationToFinite(idleTimeout)) + val read: Read[F] = timeoutMaybe(socket.read(receiveBufferSize), idleTimeout) Stream .unfoldEval[F, State, (Request[F], Response[F])](Array.emptyByteArray -> false) { case (buffer, reuse) => @@ -269,12 +273,12 @@ private[server] object ServerHelpers { private def mkConnectionInfo[F[_]: Apply](socket: Socket[F]) = (socket.localAddress, socket.remoteAddress).mapN { - case (local: InetSocketAddress, remote: InetSocketAddress) => + case (local, remote) => Vault.empty.insert( Request.Keys.ConnectionInfo, Request.Connection( - local = SocketAddress.fromInetSocketAddress(local), - remote = SocketAddress.fromInetSocketAddress(remote), + local = local, + remote = remote, secure = socket.isInstanceOf[TLSSocket[F]] ) ) diff --git a/ember-server/src/main/scala/org/http4s/ember/server/internal/Shutdown.scala b/ember-server/src/main/scala/org/http4s/ember/server/internal/Shutdown.scala index 3e40b8f606a..d76ebf5ed1e 100644 --- a/ember-server/src/main/scala/org/http4s/ember/server/internal/Shutdown.scala +++ b/ember-server/src/main/scala/org/http4s/ember/server/internal/Shutdown.scala @@ -19,7 +19,6 @@ package org.http4s.ember.server.internal import cats.syntax.all._ import cats.effect._ import cats.effect.implicits._ -import cats.effect.concurrent._ import fs2.Stream import scala.concurrent.duration.{Duration, FiniteDuration} @@ -36,15 +35,14 @@ private[server] abstract class Shutdown[F[_]] { private[server] object Shutdown { - def apply[F[_]](timeout: Duration)(implicit F: Concurrent[F], timer: Timer[F]): F[Shutdown[F]] = + def apply[F[_]](timeout: Duration)(implicit F: Temporal[F]): F[Shutdown[F]] = timeout match { case fi: FiniteDuration => if (fi.length == 0) immediateShutdown else timedShutdown(timeout) case _ => timedShutdown(timeout) } - private def timedShutdown[F[_]]( - timeout: Duration)(implicit F: Concurrent[F], timer: Timer[F]): F[Shutdown[F]] = { + private def timedShutdown[F[_]](timeout: Duration)(implicit F: Temporal[F]): F[Shutdown[F]] = { case class State(isShutdown: Boolean, active: Int) for { @@ -84,7 +82,7 @@ private[server] object Shutdown { .modify { case s @ State(isShutdown, active) => val conns = active - 1 if (isShutdown && conns <= 0) { - s.copy(active = conns) -> unblockFinish.complete(()) + s.copy(active = conns) -> unblockFinish.complete(()).void } else { s.copy(active = conns) -> F.unit } @@ -97,7 +95,7 @@ private[server] object Shutdown { private def immediateShutdown[F[_]](implicit F: Concurrent[F]): F[Shutdown[F]] = Deferred[F, Unit].map { unblock => new Shutdown[F] { - override val await: F[Unit] = unblock.complete(()) + override val await: F[Unit] = unblock.complete(()).void override val signal: F[Unit] = unblock.get override val newConnection: F[Unit] = F.unit override val removeConnection: F[Unit] = F.unit diff --git a/ember-server/src/main/scala/org/http4s/ember/server/internal/StreamForking.scala b/ember-server/src/main/scala/org/http4s/ember/server/internal/StreamForking.scala index 13cf421fd40..5662197c5e6 100644 --- a/ember-server/src/main/scala/org/http4s/ember/server/internal/StreamForking.scala +++ b/ember-server/src/main/scala/org/http4s/ember/server/internal/StreamForking.scala @@ -18,7 +18,7 @@ package org.http4s.ember.server.internal import cats.syntax.all._ import cats.effect.Concurrent -import cats.effect.concurrent.Semaphore +import cats.effect.std.Semaphore import fs2.{INothing, Stream} import fs2.concurrent.{Signal, SignallingRef} @@ -48,32 +48,17 @@ private[internal] object StreamForking { val decrementRunning: F[Unit] = running.update(_ - 1) val awaitWhileRunning: F[Unit] = running.discrete.dropWhile(_ > 0).take(1).compile.drain - val stop: F[Unit] = - done.update { - case None => Some(None) - case x => x - } - - val stopSignal: Signal[F, Boolean] = - done.map(_.nonEmpty) - - def handleResult(result: Either[Throwable, Unit]): F[Unit] = - result match { - case Right(_) => F.unit - case Left(err) => - done.update { - case None => Some(Some(err)) - case x => x - } - } + val stop: F[Unit] = done.update(_.orElse(Some(None))) + val stopSignal: Signal[F, Boolean] = done.map(_.nonEmpty) + def stopFailed(err: Throwable): F[Unit] = + done.update(_.orElse(Some(Some(err)))) def runInner(inner: Stream[F, O]): F[Unit] = { val fa = inner .interruptWhen(stopSignal) .compile .drain - .attempt - .flatMap(handleResult) >> available.release >> decrementRunning + .handleErrorWith(stopFailed) >> available.release >> decrementRunning available.acquire >> incrementRunning >> F.start(fa).void } @@ -84,9 +69,7 @@ private[internal] object StreamForking { .interruptWhen(stopSignal) .compile .drain - .void - .attempt - .flatMap(handleResult) >> decrementRunning + .handleErrorWith(stopFailed) >> decrementRunning val signalResult: F[Unit] = done.get.flatMap { diff --git a/ember-server/src/main/scala/org/http4s/ember/server/internal/WebSocketHelpers.scala b/ember-server/src/main/scala/org/http4s/ember/server/internal/WebSocketHelpers.scala index bcc5d251396..45f15d4b542 100644 --- a/ember-server/src/main/scala/org/http4s/ember/server/internal/WebSocketHelpers.scala +++ b/ember-server/src/main/scala/org/http4s/ember/server/internal/WebSocketHelpers.scala @@ -16,17 +16,18 @@ package org.http4s.ember.server.internal -import cats.effect.{Concurrent, Sync} -import cats.syntax.all._ +import cats.MonadThrow import cats.data.NonEmptyList +import cats.effect.{Async, Concurrent, Ref} +import cats.syntax.all._ import fs2.{Chunk, Pipe, Pull, Stream} -import fs2.io.tcp._ +import fs2.io.net._ import org.http4s.syntax.all._ import org.http4s._ import org.http4s.websocket.{FrameTranscoder, WebSocketContext} import org.http4s.headers._ import org.http4s.ember.core.Read -import org.http4s.ember.core.Util.durationToFinite +import org.http4s.ember.core.Util.timeoutMaybe import org.http4s.headers.Connection import org.http4s.websocket.{Rfc6455, WebSocketCombinedPipe, WebSocketFrame, WebSocketSeparatePipe} import org.typelevel.ci._ @@ -38,7 +39,8 @@ import java.nio.charset.StandardCharsets import java.nio.ByteBuffer import org.typelevel.log4cats.Logger import fs2.concurrent.SignallingRef -import cats.effect.concurrent.Ref + +import java.io.IOException object WebSocketHelpers { @@ -59,7 +61,7 @@ object WebSocketHelpers { idleTimeout: Duration, onWriteFailure: (Option[Request[F]], Response[F], Throwable) => F[Unit], errorHandler: Throwable => F[Response[F]], - logger: Logger[F])(implicit F: Concurrent[F]): F[Unit] = { + logger: Logger[F])(implicit F: Async[F]): F[Unit] = { val wsResponse = clientHandshake(req) match { case Right(key) => serverHandshake(key) @@ -84,8 +86,10 @@ object WebSocketHelpers { else F.unit } yield () - handler.handleErrorWith { e => - logger.error(e)("WebSocket connection terminated with exception") + handler.handleErrorWith { + case e @ BrokenPipeError() => + logger.trace(e)("WebSocket connection abruptly terminated by client") + case e => logger.error(e)("WebSocket connection terminated with exception") } } @@ -94,12 +98,13 @@ object WebSocketHelpers { ctx: WebSocketContext[F], buffer: Array[Byte], receiveBufferSize: Int, - idleTimeout: Duration)(implicit F: Concurrent[F]): F[Unit] = { - val read: Read[F] = socket.read(receiveBufferSize, durationToFinite(idleTimeout)) - val write = socket.writes(durationToFinite(idleTimeout)) + idleTimeout: Duration)(implicit F: Async[F]): F[Unit] = { + val read: Read[F] = timeoutMaybe(socket.read(receiveBufferSize), idleTimeout) + def write(s: Stream[F, Byte]) = + s.chunks.foreach(c => timeoutMaybe(socket.write(c), idleTimeout)) val frameTranscoder = new FrameTranscoder(false) - val incoming = Stream.chunk(Chunk.bytes(buffer)) ++ readStream(read) + val incoming = Stream.chunk(Chunk.array(buffer)) ++ readStream(read) // TODO followup: handle close frames from the user? SignallingRef[F, Close](Open).flatMap { close => @@ -177,7 +182,7 @@ object WebSocketHelpers { // TODO followup: improve the buffering here val bytes = new Array[Byte](buffer.remaining()) buffer.get(bytes) - Chunk.bytes(bytes) + Chunk.array(bytes) } Stream .iterable(chunks) @@ -185,7 +190,7 @@ object WebSocketHelpers { } private def decodeFrames[F[_]](frameTranscoder: FrameTranscoder)(implicit - F: Concurrent[F]): Pipe[F, Byte, WebSocketFrame] = stream => { + F: Async[F]): Pipe[F, Byte, WebSocketFrame] = stream => { def go(rest: Stream[F, Byte], acc: Array[Byte]): Pull[F, WebSocketFrame, Unit] = rest.pull.uncons.flatMap { case Some((chunk, next)) => @@ -236,14 +241,15 @@ object WebSocketHelpers { (connection, upgrade, version, key).mapN { case (_, _, _, key) => key } } - private def serverHandshake[F[_]](value: String)(implicit F: Sync[F]): F[ByteVector] = F.delay { - val crypt = MessageDigest.getInstance("SHA-1") - crypt.reset() - crypt.update(value.getBytes(StandardCharsets.US_ASCII)) - crypt.update(Rfc6455.handshakeMagicBytes) - val bytes = crypt.digest() - ByteVector(bytes) - } + private def serverHandshake[F[_]](value: String)(implicit F: MonadThrow[F]): F[ByteVector] = + F.catchNonFatal { + val crypt = MessageDigest.getInstance("SHA-1") + crypt.reset() + crypt.update(value.getBytes(StandardCharsets.US_ASCII)) + crypt.update(Rfc6455.handshakeMagicBytes) + val bytes = crypt.digest() + ByteVector(bytes) + } private def readStream[F[_]](read: Read[F]): Stream[F, Byte] = Stream.eval(read).flatMap { @@ -273,4 +279,8 @@ object WebSocketHelpers { extends ClientHandshakeError(Status.BadRequest, "Sec-WebSocket-Key header not present.") final case class EndOfStreamError() extends Exception("Reached End Of Stream") + + object BrokenPipeError { + def unapply(err: IOException): Boolean = err.getMessage == "Broken pipe" + } } diff --git a/ember-server/src/test/scala/org/http4s/ember/server/EmberServerMtlsSuite.scala b/ember-server/src/test/scala/org/http4s/ember/server/EmberServerMtlsSuite.scala index 00658083544..9ea99322589 100644 --- a/ember-server/src/test/scala/org/http4s/ember/server/EmberServerMtlsSuite.scala +++ b/ember-server/src/test/scala/org/http4s/ember/server/EmberServerMtlsSuite.scala @@ -18,7 +18,9 @@ package org.http4s.ember.server import cats.effect._ import cats.implicits._ -import fs2.io.tls.{TLSContext, TLSParameters} +import fs2.io.net.Network +import fs2.io.net.tls.TLSContext +import fs2.io.net.tls.TLSParameters import java.io.IOException import java.security.KeyStore @@ -66,19 +68,19 @@ class EmberServerMtlsSuite extends Http4sSuite { assert(session.X509Certificate.isEmpty) } - Ok("success") + Ok(expectedNoAuthResponse) } .orNotFound } - lazy val authTlsClientContext: Resource[IO, TLSContext] = + lazy val authTlsClientContext: Resource[IO, TLSContext[IO]] = Resource.eval( - TLSContext.fromKeyStoreResource[IO]( - "keystore.jks", - "password".toCharArray, - "password".toCharArray, - testBlocker - )) + Network[IO].tlsContext + .fromKeyStoreResource( + "keystore.jks", + "password".toCharArray, + "password".toCharArray + )) lazy val noAuthClientContext: SSLContext = { val js = KeyStore.getInstance("JKS") @@ -93,18 +95,18 @@ class EmberServerMtlsSuite extends Http4sSuite { sc } - lazy val noAuthTlsClientContext: Resource[IO, TLSContext] = + lazy val noAuthTlsClientContext: Resource[IO, TLSContext[IO]] = Resource.eval( - TLSContext.fromSSLContext(noAuthClientContext, testBlocker).pure[IO] + TLSContext.Builder.forAsync[IO].fromSSLContext(noAuthClientContext).pure[IO] ) - def fixture(tlsParams: TLSParameters, clientTlsContext: Resource[IO, TLSContext]) = + def fixture(tlsParams: TLSParameters, clientTlsContext: Resource[IO, TLSContext[IO]]) = (server(tlsParams), client(clientTlsContext)).mapN(FunFixture.map2(_, _)) - def client(tlsContextResource: Resource[IO, TLSContext]) = + def client(tlsContextResource: Resource[IO, TLSContext[IO]]) = ResourceFixture(clientResource(tlsContextResource)) - def clientResource(tlsContextResource: Resource[IO, TLSContext]) = + def clientResource(tlsContextResource: Resource[IO, TLSContext[IO]]) = for { tlsContext <- tlsContextResource emberClient <- EmberClientBuilder @@ -152,8 +154,7 @@ class EmberServerMtlsSuite extends Http4sSuite { noAuthTlsClientContext).test("Server should fail for invalid client auth") { case (server, client) => client - .get(s"https://${server.address.getHostName}:${server.address.getPort}/dummy")( - _.status.pure[IO]) + .statusFromString(s"https://${server.address.getHostName}:${server.address.getPort}/dummy") .intercept[IOException] } diff --git a/ember-server/src/test/scala/org/http4s/ember/server/EmberServerSuite.scala b/ember-server/src/test/scala/org/http4s/ember/server/EmberServerSuite.scala index 84310ec824c..c50e6e97b5a 100644 --- a/ember-server/src/test/scala/org/http4s/ember/server/EmberServerSuite.scala +++ b/ember-server/src/test/scala/org/http4s/ember/server/EmberServerSuite.scala @@ -25,7 +25,7 @@ import org.http4s.implicits._ import org.http4s.dsl.Http4sDsl import org.http4s.ember.client.EmberClientBuilder -import java.net.BindException +import java.net.{BindException, ConnectException} class EmberServerSuite extends Http4sSuite { @@ -67,6 +67,14 @@ class EmberServerSuite extends Http4sSuite { .assertEquals(Status.Ok) } + client.test("server shuts down after exiting resource scope") { client => + serverResource.use(server => IO.pure(server.address)).flatMap { address => + client + .get(s"http://${address.getHostName}:${address.getPort}")(_.status.pure[IO]) + .intercept[ConnectException] + } + } + server().test("server startup fails if address is already in use") { case _ => serverResource.use(_ => IO.unit).intercept[BindException] } @@ -79,7 +87,7 @@ class EmberServerSuite extends Http4sSuite { import org.http4s.client.dsl.io._ val body: Stream[IO, Byte] = - Stream.emits(Seq("hello")).repeatN(256).through(fs2.text.utf8Encode).covary[IO] + Stream.emits(Seq("hello")).repeatN(256).through(fs2.text.utf8.encode).covary[IO] val expected = "hello" * 256 val uri = Uri diff --git a/ember-server/src/test/scala/org/http4s/ember/server/EmberServerWebSocketSuite.scala b/ember-server/src/test/scala/org/http4s/ember/server/EmberServerWebSocketSuite.scala index 00436ccc95c..3dda3028bc3 100644 --- a/ember-server/src/test/scala/org/http4s/ember/server/EmberServerWebSocketSuite.scala +++ b/ember-server/src/test/scala/org/http4s/ember/server/EmberServerWebSocketSuite.scala @@ -18,26 +18,26 @@ package org.http4s.ember.server import cats.syntax.all._ import cats.effect._ +import cats.effect.std.{Dispatcher, Queue} import fs2.{Pipe, Stream} import org.http4s._ import org.http4s.server.Server import org.http4s.implicits._ import org.http4s.dsl.Http4sDsl +import org.http4s.testing.DispatcherIOFixture import org.http4s.websocket.WebSocketFrame import org.http4s.server.websocket.WebSocketBuilder import org.java_websocket.client.WebSocketClient import java.net.URI -import cats.effect.concurrent.Deferred import org.java_websocket.handshake.ServerHandshake -import fs2.concurrent.Queue import org.java_websocket.WebSocket import org.java_websocket.framing.Framedata import org.java_websocket.framing.PingFrame import java.nio.ByteBuffer import java.nio.charset.StandardCharsets -class EmberServerWebSocketSuite extends Http4sSuite { +class EmberServerWebSocketSuite extends Http4sSuite with DispatcherIOFixture { def service[F[_]](implicit F: Async[F]): HttpApp[F] = { val dsl = new Http4sDsl[F] {} @@ -66,7 +66,7 @@ class EmberServerWebSocketSuite extends Http4sSuite { .withHttpApp(service[IO]) .build - def fixture = ResourceFixture(serverResource) + def fixture = (ResourceFixture(serverResource), dispatcher).mapN(FunFixture.map2(_, _)) case class Client( waitOpen: Deferred[IO, Option[Throwable]], @@ -87,7 +87,7 @@ class EmberServerWebSocketSuite extends Http4sSuite { } } - def createClient(target: URI): IO[Client] = + def createClient(target: URI, dispatcher: Dispatcher[IO]): IO[Client] = for { waitOpen <- Deferred[IO, Option[Throwable]] waitClose <- Deferred[IO, Option[Throwable]] @@ -97,66 +97,75 @@ class EmberServerWebSocketSuite extends Http4sSuite { client = new WebSocketClient(target) { override def onOpen(handshakedata: ServerHandshake): Unit = { val fa = waitOpen.complete(None) - fa.unsafeRunSync() + dispatcher.unsafeRunSync(fa) + () } override def onClose(code: Int, reason: String, remote: Boolean): Unit = { val fa = waitOpen .complete(Some(new Throwable(s"closed: code: $code, reason: $reason"))) .attempt >> waitClose.complete(None) - fa.unsafeRunSync() + dispatcher.unsafeRunSync(fa) + () } override def onMessage(msg: String): Unit = - queue.enqueue1(msg).unsafeRunSync() + dispatcher.unsafeRunSync(queue.offer(msg)) override def onError(ex: Exception): Unit = { val fa = waitOpen.complete(Some(ex)).attempt >> waitClose.complete(Some(ex)).attempt.void - fa.unsafeRunSync() + dispatcher.unsafeRunSync(fa) + } + override def onWebsocketPong(conn: WebSocket, f: Framedata): Unit = { + val fa = pongQueue + .offer(new String(f.getPayloadData().array(), StandardCharsets.UTF_8)) + dispatcher.unsafeRunSync(fa) + } + override def onClosing(code: Int, reason: String, remote: Boolean): Unit = { + dispatcher.unsafeRunSync(remoteClosed.complete(())) + () } - override def onWebsocketPong(conn: WebSocket, f: Framedata): Unit = - pongQueue - .enqueue1(new String(f.getPayloadData().array(), StandardCharsets.UTF_8)) - .unsafeRunSync() - override def onClosing(code: Int, reason: String, remote: Boolean): Unit = - remoteClosed.complete(()).unsafeRunSync() } } yield Client(waitOpen, waitClose, queue, pongQueue, remoteClosed, client) - fixture.test("open and close connection to server") { server => + fixture.test("open and close connection to server") { case (server, dispatcher) => for { client <- createClient( - URI.create(s"ws://${server.address.getHostName}:${server.address.getPort}/ws-echo")) + URI.create(s"ws://${server.address.getHostName}:${server.address.getPort}/ws-echo"), + dispatcher) _ <- client.connect _ <- client.close } yield () } - fixture.test("send and receive a message") { server => + fixture.test("send and receive a message") { case (server, dispatcher) => for { client <- createClient( - URI.create(s"ws://${server.address.getHostName}:${server.address.getPort}/ws-echo")) + URI.create(s"ws://${server.address.getHostName}:${server.address.getPort}/ws-echo"), + dispatcher) _ <- client.connect _ <- client.send("foo") - msg <- client.messages.dequeue1 + msg <- client.messages.take _ <- client.close } yield assertEquals(msg, "foo") } - fixture.test("respond to pings") { server => + fixture.test("respond to pings") { case (server, dispatcher) => for { client <- createClient( - URI.create(s"ws://${server.address.getHostName}:${server.address.getPort}/ws-echo")) + URI.create(s"ws://${server.address.getHostName}:${server.address.getPort}/ws-echo"), + dispatcher) _ <- client.connect _ <- client.ping("hello") - data <- client.pongs.dequeue1 + data <- client.pongs.take _ <- client.close } yield assertEquals(data, "hello") } - fixture.test("initiate close sequence on stream termination") { server => + fixture.test("initiate close sequence on stream termination") { case (server, dispatcher) => for { client <- createClient( - URI.create(s"ws://${server.address.getHostName}:${server.address.getPort}/ws-close")) + URI.create(s"ws://${server.address.getHostName}:${server.address.getPort}/ws-close"), + dispatcher) _ <- client.connect - _ <- client.messages.dequeue1 + _ <- client.messages.take _ <- client.remoteClosed.get } yield () } diff --git a/ember-server/src/test/scala/org/http4s/ember/server/internal/StreamForkingSuite.scala b/ember-server/src/test/scala/org/http4s/ember/server/internal/StreamForkingSuite.scala index 0a429a65a26..b892065bcba 100644 --- a/ember-server/src/test/scala/org/http4s/ember/server/internal/StreamForkingSuite.scala +++ b/ember-server/src/test/scala/org/http4s/ember/server/internal/StreamForkingSuite.scala @@ -16,9 +16,8 @@ package org.http4s.ember.server.internal -import cats.syntax.all._ import cats.effect.IO -import cats.effect.concurrent.{Deferred, Ref} +import cats.effect.kernel.{Deferred, Ref} import fs2.Stream import munit._ @@ -46,7 +45,7 @@ class StreamForkingSuite extends CatsEffectSuite { test("outer stream can terminate and finalize before inner streams complete") { Deferred[IO, Unit] .flatMap { gate => - val stream = Stream.bracket(IO.unit)(_ => gate.complete(())) >> Stream( + val stream = Stream.bracket(IO.unit)(_ => gate.complete(()).void) >> Stream( Stream.eval(gate.get) ) diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeExample.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeExample.scala index c6faf893631..77414870c92 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeExample.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeExample.scala @@ -29,18 +29,16 @@ object BlazeExample extends IOApp { } object BlazeExampleApp { - def httpApp[F[_]: Effect: ContextShift: Timer](blocker: Blocker): HttpApp[F] = + def httpApp[F[_]: Async]: HttpApp[F] = Router( - "/http4s" -> ExampleService[F](blocker).routes + "/http4s" -> ExampleService[F].routes ).orNotFound - def resource[F[_]: ConcurrentEffect: ContextShift: Timer]: Resource[F, Server] = - for { - blocker <- Blocker[F] - app = httpApp[F](blocker) - server <- BlazeServerBuilder[F](global) - .bindHttp(8080) - .withHttpApp(app) - .resource - } yield server + def resource[F[_]: Async]: Resource[F, Server] = { + val app = httpApp[F] + BlazeServerBuilder[F](global) + .bindHttp(8080) + .withHttpApp(app) + .resource + } } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeMetricsExample.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeMetricsExample.scala index a2c75b520b5..341f452efd3 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeMetricsExample.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeMetricsExample.scala @@ -33,22 +33,20 @@ class BlazeMetricsExample extends IOApp { } object BlazeMetricsExampleApp { - def httpApp[F[_]: ConcurrentEffect: ContextShift: Timer](blocker: Blocker): HttpApp[F] = { + def httpApp[F[_]: Async]: HttpApp[F] = { val metricsRegistry: MetricRegistry = new MetricRegistry() val metrics: HttpMiddleware[F] = Metrics[F](Dropwizard(metricsRegistry, "server")) Router( - "/http4s" -> metrics(ExampleService[F](blocker).routes), + "/http4s" -> metrics(ExampleService[F].routes), "/http4s/metrics" -> metricsService[F](metricsRegistry) ).orNotFound } - def resource[F[_]: ConcurrentEffect: ContextShift: Timer]: Resource[F, Server] = - for { - blocker <- Blocker[F] - app = httpApp[F](blocker) - server <- BlazeServerBuilder[F](global) - .bindHttp(8080) - .withHttpApp(app) - .resource - } yield server + def resource[F[_]: Async]: Resource[F, Server] = { + val app = httpApp[F] + BlazeServerBuilder[F](global) + .bindHttp(8080) + .withHttpApp(app) + .resource + } } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeSslExample.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeSslExample.scala index 1a63f61ba1c..a0d3585e433 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeSslExample.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeSslExample.scala @@ -32,17 +32,16 @@ object BlazeSslExampleApp { def context[F[_]: Sync] = ssl.loadContextFromClasspath(ssl.keystorePassword, ssl.keyManagerPassword) - def builder[F[_]: ConcurrentEffect: Timer]: F[BlazeServerBuilder[F]] = + def builder[F[_]: Async]: F[BlazeServerBuilder[F]] = context.map { sslContext => BlazeServerBuilder[F](global) .bindHttp(8443) .withSslContext(sslContext) } - def resource[F[_]: ConcurrentEffect: ContextShift: Timer]: Resource[F, Server] = + def resource[F[_]: Async]: Resource[F, Server] = for { - blocker <- Blocker[F] b <- Resource.eval(builder[F]) - server <- b.withHttpApp(BlazeExampleApp.httpApp(blocker)).resource + server <- b.withHttpApp(BlazeExampleApp.httpApp).resource } yield server } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeSslExampleWithRedirect.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeSslExampleWithRedirect.scala index 1572ac6b751..b08f3edf5db 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeSslExampleWithRedirect.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeSslExampleWithRedirect.scala @@ -34,12 +34,12 @@ object BlazeSslExampleWithRedirect extends IOApp { } object BlazeSslExampleWithRedirectApp { - def redirectStream[F[_]: ConcurrentEffect: Timer]: Stream[F, ExitCode] = + def redirectStream[F[_]: Async]: Stream[F, ExitCode] = BlazeServerBuilder[F](global) .bindHttp(8080) .withHttpApp(ssl.redirectApp(8443)) .serve - def sslStream[F[_]: ConcurrentEffect: Timer]: Stream[F, ExitCode] = + def sslStream[F[_]: Async]: Stream[F, ExitCode] = Stream.eval(BlazeSslExampleApp.builder[F]).flatMap(_.serve) } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeWebSocketExample.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeWebSocketExample.scala index 8f37863d9ef..dc8aa8a1745 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeWebSocketExample.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/BlazeWebSocketExample.scala @@ -17,9 +17,9 @@ package com.example.http4s.blaze import cats.effect._ +import cats.effect.std.Queue import cats.syntax.all._ import fs2._ -import fs2.concurrent.Queue import org.http4s._ import org.http4s.blaze.server.BlazeServerBuilder import org.http4s.implicits._ @@ -27,6 +27,7 @@ import org.http4s.dsl.Http4sDsl import org.http4s.server.websocket._ import org.http4s.websocket.WebSocketFrame import org.http4s.websocket.WebSocketFrame._ + import scala.concurrent.duration._ import scala.concurrent.ExecutionContext.global @@ -35,8 +36,7 @@ object BlazeWebSocketExample extends IOApp { BlazeWebSocketExampleApp[IO].stream.compile.drain.as(ExitCode.Success) } -class BlazeWebSocketExampleApp[F[_]](implicit F: ConcurrentEffect[F], timer: Timer[F]) - extends Http4sDsl[F] { +class BlazeWebSocketExampleApp[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { def routes: HttpRoutes[F] = HttpRoutes.of[F] { case GET -> Root / "hello" => @@ -71,10 +71,10 @@ class BlazeWebSocketExampleApp[F[_]](implicit F: ConcurrentEffect[F], timer: Tim * instead of to a request. */ Queue - .unbounded[F, WebSocketFrame] + .unbounded[F, Option[WebSocketFrame]] .flatMap { q => - val d = q.dequeue.through(echoReply) - val e = q.enqueue + val d: Stream[F, WebSocketFrame] = Stream.fromQueueNoneTerminated(q).through(echoReply) + val e: Pipe[F, WebSocketFrame, Unit] = _.enqueueNoneTerminated(q) WebSocketBuilder[F].build(d, e) } } @@ -87,6 +87,6 @@ class BlazeWebSocketExampleApp[F[_]](implicit F: ConcurrentEffect[F], timer: Tim } object BlazeWebSocketExampleApp { - def apply[F[_]: ConcurrentEffect: Timer]: BlazeWebSocketExampleApp[F] = + def apply[F[_]: Async]: BlazeWebSocketExampleApp[F] = new BlazeWebSocketExampleApp[F] } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientExample.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientExample.scala index f8e55ddefbb..2bca3513be9 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientExample.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientExample.scala @@ -23,37 +23,46 @@ import org.http4s.circe._ import org.http4s.syntax.all._ import org.http4s.client.Client import org.http4s.blaze.client.BlazeClientBuilder -import scala.concurrent.ExecutionContext.global object ClientExample extends IOApp { - def getSite(client: Client[IO]): IO[Unit] = - IO { - val page: IO[String] = client.expect[String](uri"https://www.google.com/") - - for (_ <- 1 to 2) - println( - page.map(_.take(72)).unsafeRunSync() - ) // each execution of the effect will refetch the page! - - // We can do much more: how about decoding some JSON to a scala object - // after matching based on the response status code? + def printGooglePage(client: Client[IO]): IO[Unit] = { + val page: IO[String] = client.expect[String](uri"https://www.google.com/") + IO.parSequenceN(2)((1 to 2).toList.map { _ => + for { + // each execution of the effect will refetch the page! + pageContent <- page + firstBytes = pageContent.take(72) + _ <- IO.println(firstBytes) + } yield () + }).as(()) + } - final case class Foo(bar: String) + def matchOnResponseCode(client: Client[IO]): IO[Unit] = { + final case class Foo(bar: String) + for { // Match on response code! - val page2 = client.get(uri"http://http4s.org/resources/foo.json") { + page <- client.get(uri"http://http4s.org/resources/foo.json") { case Successful(resp) => // decodeJson is defined for Circe, just need the right decoder! resp.decodeJson[Foo].map("Received response: " + _) case NotFound(_) => IO.pure("Not Found!!!") case resp => IO.pure("Failed: " + resp.status) } + _ <- IO.println(page) + } yield () + } - println(page2.unsafeRunSync()) - } + def getSite(client: Client[IO]): IO[Unit] = + for { + _ <- printGooglePage(client) + // We can do much more: how about decoding some JSON to a scala object + // after matching based on the response status code? + _ <- matchOnResponseCode(client) + } yield () def run(args: List[String]): IO[ExitCode] = - BlazeClientBuilder[IO](global).resource + BlazeClientBuilder[IO](scala.concurrent.ExecutionContext.global).resource .use(getSite) .as(ExitCode.Success) } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientMultipartPostExample.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientMultipartPostExample.scala index 9e583ba6146..7b6e19f9a3a 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientMultipartPostExample.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientMultipartPostExample.scala @@ -16,7 +16,7 @@ package com.example.http4s.blaze -import cats.effect.{Blocker, ExitCode, IO, IOApp} +import cats.effect.{ExitCode, IO, IOApp} import java.net.URL import org.http4s._ import org.http4s.Uri._ @@ -28,8 +28,6 @@ import org.http4s.multipart._ import scala.concurrent.ExecutionContext.global object ClientMultipartPostExample extends IOApp with Http4sClientDsl[IO] { - val blocker = Blocker.liftExecutionContext(global) - val bottle: URL = getClass.getResource("/beerbottle.png") def go(client: Client[IO]): IO[String] = { @@ -42,7 +40,7 @@ object ClientMultipartPostExample extends IOApp with Http4sClientDsl[IO] { val multipart = Multipart[IO]( Vector( Part.formData("text", "This is text."), - Part.fileData("BALL", bottle, blocker, `Content-Type`(MediaType.image.png)) + Part.fileData("BALL", bottle, `Content-Type`(MediaType.image.png)) )) val request: Request[IO] = @@ -54,6 +52,6 @@ object ClientMultipartPostExample extends IOApp with Http4sClientDsl[IO] { def run(args: List[String]): IO[ExitCode] = BlazeClientBuilder[IO](global).resource .use(go) - .flatMap(s => IO(println(s))) + .flatMap(s => IO.println(s)) .as(ExitCode.Success) } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientPostExample.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientPostExample.scala index adccb199101..e8be97a690c 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientPostExample.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/ClientPostExample.scala @@ -29,6 +29,6 @@ object ClientPostExample extends IOApp with Http4sClientDsl[IO] { def run(args: List[String]): IO[ExitCode] = { val req = POST(UrlForm("q" -> "http4s"), uri"https://duckduckgo.com/") val responseBody = BlazeClientBuilder[IO](global).resource.use(_.expect[String](req)) - responseBody.flatMap(resp => IO(println(resp))).as(ExitCode.Success) + responseBody.flatMap(resp => IO.println(resp)).as(ExitCode.Success) } } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/client/MultipartClient.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/client/MultipartClient.scala index cc43b6a9f01..8a87d1986e8 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/client/MultipartClient.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/client/MultipartClient.scala @@ -16,7 +16,7 @@ package com.example.http4s.blaze.demo.client -import cats.effect.{Blocker, ExitCode, IO, IOApp, Resource} +import cats.effect.{ExitCode, IO, IOApp, Resource} import com.example.http4s.blaze.demo.StreamUtils import fs2.Stream import java.net.URL @@ -33,31 +33,28 @@ import scala.concurrent.ExecutionContext.global object MultipartClient extends MultipartHttpClient class MultipartHttpClient(implicit S: StreamUtils[IO]) extends IOApp with Http4sClientDsl[IO] { - private val image: IO[URL] = IO(getClass.getResource("/beerbottle.png")) + private val image: IO[URL] = IO.blocking(getClass.getResource("/beerbottle.png")) - private def multipart(url: URL, blocker: Blocker) = + private def multipart(url: URL) = Multipart[IO]( Vector( Part.formData("name", "gvolpe"), - Part.fileData("rick", url, blocker, `Content-Type`(MediaType.image.png)) + Part.fileData("rick", url, `Content-Type`(MediaType.image.png)) ) ) - private def request(blocker: Blocker) = + private def request = image - .map(multipart(_, blocker)) + .map(multipart) .map(body => POST(body, uri"http://localhost:8080/v1/multipart").withHeaders(body.headers)) - private val resources: Resource[IO, (Blocker, Client[IO])] = - for { - blocker <- Blocker[IO] - client <- BlazeClientBuilder[IO](global).resource - } yield (blocker, client) + private val resources: Resource[IO, Client[IO]] = + BlazeClientBuilder[IO](global).resource private val example = for { - (blocker, client) <- Stream.resource(resources) - req <- Stream.eval(request(blocker)) + client <- Stream.resource(resources) + req <- Stream.eval(request) value <- Stream.eval(client.expect[String](req)) _ <- S.putStrLn(value) } yield () diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/client/StreamClient.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/client/StreamClient.scala index f298ac75025..fbf85fcfabf 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/client/StreamClient.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/client/StreamClient.scala @@ -16,12 +16,13 @@ package com.example.http4s.blaze.demo.client -import cats.effect.{ConcurrentEffect, ExitCode, IO, IOApp} +import cats.effect.{Async, ExitCode, IO, IOApp} import com.example.http4s.blaze.demo.StreamUtils import io.circe.Json import org.http4s.blaze.client.BlazeClientBuilder import org.http4s.{Request, Uri} import org.typelevel.jawn.Facade + import scala.concurrent.ExecutionContext.Implicits.global object StreamClient extends IOApp { @@ -29,7 +30,7 @@ object StreamClient extends IOApp { new HttpClient[IO].run.as(ExitCode.Success) } -class HttpClient[F[_]](implicit F: ConcurrentEffect[F], S: StreamUtils[F]) { +class HttpClient[F[_]](implicit F: Async[F], S: StreamUtils[F]) { implicit val jsonFacade: Facade[Json] = new io.circe.jawn.CirceSupportParser(None, false).facade @@ -39,7 +40,7 @@ class HttpClient[F[_]](implicit F: ConcurrentEffect[F], S: StreamUtils[F]) { val request = Request[F](uri = Uri.unsafeFromString("http://localhost:8080/v1/dirs?depth=3")) for { - response <- client.stream(request).flatMap(_.body.chunks.through(fs2.text.utf8DecodeC)) + response <- client.stream(request).flatMap(_.body.chunks.through(fs2.text.utf8.decodeC)) _ <- S.putStr(response) } yield () } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/Module.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/Module.scala index 14ecd2bc50f..35e956150e1 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/Module.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/Module.scala @@ -32,8 +32,8 @@ import org.http4s.server.middleware.{AutoSlash, ChunkAggregator, GZip, Timeout} import scala.concurrent.duration._ -class Module[F[_]: ConcurrentEffect: ContextShift: Timer](client: Client[F], blocker: Blocker) { - private val fileService = new FileService[F](blocker) +class Module[F[_]: Async](client: Client[F]) { + private val fileService = new FileService[F] private val gitHubService = new GitHubService[F](client) diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/Server.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/Server.scala index d0ee5bc9e18..0ba7f5bf352 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/Server.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/Server.scala @@ -38,11 +38,10 @@ object HttpServer { "/" -> ctx.httpServices ).orNotFound - def stream[F[_]: ConcurrentEffect: ContextShift: Timer]: Stream[F, ExitCode] = + def stream[F[_]: Async]: Stream[F, ExitCode] = for { - blocker <- Stream.resource(Blocker[F]) client <- BlazeClientBuilder[F](global).stream - ctx <- Stream(new Module[F](client, blocker)) + ctx <- Stream(new Module[F](client)) exitCode <- BlazeServerBuilder[F](global) .bindHttp(8080) .withHttpApp(httpApp(ctx)) diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/JsonXmlHttpEndpoint.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/JsonXmlHttpEndpoint.scala index 017e3f032b6..62744b4191e 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/JsonXmlHttpEndpoint.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/JsonXmlHttpEndpoint.scala @@ -16,7 +16,7 @@ package com.example.http4s.blaze.demo.server.endpoints -import cats.effect.Effect +import cats.effect.Async import cats.syntax.flatMap._ import io.circe.generic.auto._ import org.http4s.{ApiVersion => _, _} @@ -26,7 +26,7 @@ import org.http4s.dsl.Http4sDsl import scala.xml._ // Docs: http://http4s.org/v0.18/entity/ -class JsonXmlHttpEndpoint[F[_]](implicit F: Effect[F]) extends Http4sDsl[F] { +class JsonXmlHttpEndpoint[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { case class Person(name: String, age: Int) /** XML Example for Person: diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/MultipartHttpEndpoint.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/MultipartHttpEndpoint.scala index 9b7d75d9678..8a080214bc8 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/MultipartHttpEndpoint.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/MultipartHttpEndpoint.scala @@ -16,7 +16,7 @@ package com.example.http4s.blaze.demo.server.endpoints -import cats.effect.Sync +import cats.effect.Concurrent import cats.syntax.all._ import com.example.http4s.blaze.demo.server.service.FileService import org.http4s.EntityDecoder.multipart @@ -24,8 +24,7 @@ import org.http4s.{ApiVersion => _, _} import org.http4s.dsl.Http4sDsl import org.http4s.multipart.Part -class MultipartHttpEndpoint[F[_]](fileService: FileService[F])(implicit F: Sync[F]) - extends Http4sDsl[F] { +class MultipartHttpEndpoint[F[_]: Concurrent](fileService: FileService[F]) extends Http4sDsl[F] { val service: HttpRoutes[F] = HttpRoutes.of { case GET -> Root / ApiVersion / "multipart" => Ok("Send a file (image, sound, etc) via POST Method") diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/TimeoutHttpEndpoint.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/TimeoutHttpEndpoint.scala index c1cece18c4b..1b47982262e 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/TimeoutHttpEndpoint.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/endpoints/TimeoutHttpEndpoint.scala @@ -16,7 +16,7 @@ package com.example.http4s.blaze.demo.server.endpoints -import cats.effect.{Async, Timer} +import cats.effect.Async import cats.syntax.all._ import java.util.concurrent.TimeUnit import org.http4s.{ApiVersion => _, _} @@ -24,9 +24,9 @@ import org.http4s.dsl.Http4sDsl import scala.concurrent.duration.FiniteDuration import scala.util.Random -class TimeoutHttpEndpoint[F[_]](implicit F: Async[F], timer: Timer[F]) extends Http4sDsl[F] { +class TimeoutHttpEndpoint[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { val service: HttpRoutes[F] = HttpRoutes.of { case GET -> Root / ApiVersion / "timeout" => val randomDuration = FiniteDuration(Random.nextInt(3) * 1000L, TimeUnit.MILLISECONDS) - timer.sleep(randomDuration) *> Ok("delayed response") + F.sleep(randomDuration) *> Ok("delayed response") } } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/service/FileService.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/service/FileService.scala index 34b7f814711..313f8ede573 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/service/FileService.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/service/FileService.scala @@ -18,12 +18,13 @@ package com.example.http4s.blaze.demo.server.service import java.io.File import java.nio.file.Paths -import cats.effect.{Blocker, ContextShift, Effect} +import cats.effect.Async import com.example.http4s.blaze.demo.StreamUtils import fs2.Stream +import fs2.io.file.{Files, Path} import org.http4s.multipart.Part -class FileService[F[_]: ContextShift](blocker: Blocker)(implicit F: Effect[F], S: StreamUtils[F]) { +class FileService[F[_]](implicit F: Async[F], S: StreamUtils[F]) { def homeDirectories(depth: Option[Int]): Stream[F, String] = S.env("HOME").flatMap { maybePath => val ifEmpty = S.error("HOME environment variable not found!") @@ -52,6 +53,6 @@ class FileService[F[_]: ContextShift](blocker: Blocker)(implicit F: Effect[F], S home <- S.evalF(sys.env.getOrElse("HOME", "/tmp")) filename <- S.evalF(part.filename.getOrElse("sample")) path <- S.evalF(Paths.get(s"$home/$filename")) - _ <- part.body.through(fs2.io.file.writeAll(path, blocker)) - } yield () + result <- part.body.through(Files[F].writeAll(Path.fromNioPath(path))) + } yield result } diff --git a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/service/GitHubService.scala b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/service/GitHubService.scala index ef6c9389fd3..37ef11c1df0 100644 --- a/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/service/GitHubService.scala +++ b/examples/blaze/src/main/scala/com/example/http4s/blaze/demo/server/service/GitHubService.scala @@ -16,7 +16,7 @@ package com.example.http4s.blaze.demo.server.service -import cats.effect.Sync +import cats.effect.Concurrent import cats.syntax.functor._ import com.example.http4s.blaze.demo.server.endpoints.ApiVersion import fs2.Stream @@ -28,7 +28,7 @@ import org.http4s.Request import org.http4s.syntax.literals._ // See: https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/#web-application-flow -class GitHubService[F[_]: Sync](client: Client[F]) extends Http4sClientDsl[F] { +class GitHubService[F[_]: Concurrent](client: Client[F]) extends Http4sClientDsl[F] { // NEVER make this data public! This is just a demo! private val ClientId = "959ea01cd3065cad274a" private val ClientSecret = "53901db46451977e6331432faa2616ba24bc2550" diff --git a/examples/docker/src/main/scala/com/example/http4s/Example.scala b/examples/docker/src/main/scala/com/example/http4s/Example.scala index a7acefe2bcb..cf37467b713 100644 --- a/examples/docker/src/main/scala/com/example/http4s/Example.scala +++ b/examples/docker/src/main/scala/com/example/http4s/Example.scala @@ -29,7 +29,7 @@ object Main extends IOApp { } object ExampleApp { - def serverStream[F[_]: ConcurrentEffect: Timer]: Stream[F, ExitCode] = + def serverStream[F[_]: Async]: Stream[F, ExitCode] = BlazeServerBuilder[F](global) .bindHttp(port = 8080, host = "0.0.0.0") .withHttpApp(ExampleRoutes[F]().routes.orNotFound) diff --git a/examples/ember/src/main/scala/com/example/http4s/ember/EmberClientSimpleExample.scala b/examples/ember/src/main/scala/com/example/http4s/ember/EmberClientSimpleExample.scala index 7d1e6cb6a5f..a699eb6d0e4 100644 --- a/examples/ember/src/main/scala/com/example/http4s/ember/EmberClientSimpleExample.scala +++ b/examples/ember/src/main/scala/com/example/http4s/ember/EmberClientSimpleExample.scala @@ -16,7 +16,6 @@ package com.example.http4s.ember -import cats._ import cats.effect._ import cats.syntax.all._ import org.http4s._ @@ -30,7 +29,6 @@ import fs2._ import _root_.org.typelevel.log4cats.Logger import _root_.org.typelevel.log4cats.slf4j.Slf4jLogger import scala.concurrent.duration._ -import java.util.concurrent.TimeUnit import scodec.bits.ByteVector object EmberClientSimpleExample extends IOApp { @@ -65,23 +63,23 @@ object EmberClientSimpleExample extends IOApp { IO(println("Done"))) .as(ExitCode.Success) - def getRequestBufferedBody[F[_]: Sync](client: Client[F], req: Request[F]): F[Response[F]] = + def getRequestBufferedBody[F[_]: Async](client: Client[F], req: Request[F]): F[Response[F]] = client .run(req) .use(resp => resp.body.compile .to(ByteVector) - .map(bv => resp.copy(body = Stream.chunk(Chunk.ByteVectorChunk(bv))))) + .map(bv => resp.copy(body = Stream.chunk(Chunk.byteVector(bv))))) - def logTimed[F[_]: Clock: Monad, A](logger: Logger[F], name: String, fa: F[A]): F[A] = + def logTimed[F[_]: Temporal, A](logger: Logger[F], name: String, fa: F[A]): F[A] = timedMS(fa).flatMap { case (time, action) => logger.info(s"Action $name took $time").as(action) } - def timedMS[F[_]: Clock: Applicative, A](fa: F[A]): F[(FiniteDuration, A)] = { - val nowMS = Clock[F].monotonic(TimeUnit.MILLISECONDS) + def timedMS[F[_]: Temporal, A](fa: F[A]): F[(FiniteDuration, A)] = { + val nowMS = Temporal[F].monotonic (nowMS, fa, nowMS).mapN { case (before, result, after) => - val time = (after - before).millis + val time = after - before (time, result) } } diff --git a/examples/ember/src/main/scala/com/example/http4s/ember/EmberServerSimpleExample.scala b/examples/ember/src/main/scala/com/example/http4s/ember/EmberServerSimpleExample.scala index 88bd49f9663..60aad1fd107 100644 --- a/examples/ember/src/main/scala/com/example/http4s/ember/EmberServerSimpleExample.scala +++ b/examples/ember/src/main/scala/com/example/http4s/ember/EmberServerSimpleExample.scala @@ -19,6 +19,7 @@ package com.example.http4s.ember import fs2._ import cats.effect._ import cats.syntax.all._ +import com.comcast.ip4s._ import org.http4s._ import org.http4s.implicits._ import org.http4s.dsl.Http4sDsl @@ -32,8 +33,8 @@ import scala.concurrent.duration._ object EmberServerSimpleExample extends IOApp { def run(args: List[String]): IO[ExitCode] = { - val host = "0.0.0.0" - val port = 8080 + val host = host"0.0.0.0" + val port = port"8080" for { // Server Level Resources Here server <- @@ -48,7 +49,7 @@ object EmberServerSimpleExample extends IOApp { IO.delay(println(s"Server Has Started at ${server.address}")) >> IO.never.as(ExitCode.Success)) - def service[F[_]: Sync: Timer]: HttpApp[F] = { + def service[F[_]: Async]: HttpApp[F] = { val dsl = new Http4sDsl[F] {} import dsl._ @@ -68,7 +69,7 @@ object EmberServerSimpleExample extends IOApp { .covary[F] .repeat .take(100) - .through(fs2.text.utf8Encode[F]) + .through(fs2.text.utf8.encode[F]) Ok(body).map(_.withContentType(headers.`Content-Type`(MediaType.text.plain))) case GET -> Root / "ws" => val send: Stream[F, WebSocketFrame] = diff --git a/examples/jetty/src/main/scala/com/example/http4s/jetty/JettyExample.scala b/examples/jetty/src/main/scala/com/example/http4s/jetty/JettyExample.scala index e7e731bb5e4..67d627b3794 100644 --- a/examples/jetty/src/main/scala/com/example/http4s/jetty/JettyExample.scala +++ b/examples/jetty/src/main/scala/com/example/http4s/jetty/JettyExample.scala @@ -30,20 +30,17 @@ object JettyExample extends IOApp { } object JettyExampleApp { - def builder[F[_]: ConcurrentEffect: ContextShift: Timer](blocker: Blocker): JettyBuilder[F] = { + def builder[F[_]: Async]: JettyBuilder[F] = { val metricsRegistry: MetricRegistry = new MetricRegistry val metrics: HttpMiddleware[F] = Metrics[F](Dropwizard(metricsRegistry, "server")) JettyBuilder[F] .bindHttp(8080) - .mountService(metrics(ExampleService[F](blocker).routes), "/http4s") + .mountService(metrics(ExampleService[F].routes), "/http4s") .mountService(metricsService(metricsRegistry), "/metrics") .mountFilter(NoneShallPass, "/black-knight/*") } - def resource[F[_]: ConcurrentEffect: ContextShift: Timer]: Resource[F, Server] = - for { - blocker <- Blocker[F] - server <- builder[F](blocker).resource - } yield server + def resource[F[_]: Async]: Resource[F, Server] = + builder[F].resource } diff --git a/examples/jetty/src/main/scala/com/example/http4s/jetty/JettySslExample.scala b/examples/jetty/src/main/scala/com/example/http4s/jetty/JettySslExample.scala index e199bec5146..b2ae4a95a49 100644 --- a/examples/jetty/src/main/scala/com/example/http4s/jetty/JettySslExample.scala +++ b/examples/jetty/src/main/scala/com/example/http4s/jetty/JettySslExample.scala @@ -31,18 +31,17 @@ object JettySslExampleApp { def sslContext[F[_]: Sync] = ssl.loadContextFromClasspath(ssl.keystorePassword, ssl.keyManagerPassword) - def builder[F[_]: ConcurrentEffect: ContextShift: Timer](blocker: Blocker): F[JettyBuilder[F]] = + def builder[F[_]: Async]: F[JettyBuilder[F]] = sslContext.map { sslCtx => JettyExampleApp - .builder[F](blocker) + .builder[F] .bindHttp(8443) .withSslContext(sslCtx) } - def resource[F[_]: ConcurrentEffect: ContextShift: Timer]: Resource[F, Server] = + def resource[F[_]: Async]: Resource[F, Server] = for { - blocker <- Blocker[F] - b <- Resource.eval(builder[F](blocker)) + b <- Resource.eval(builder[F]) server <- b.resource } yield server } diff --git a/examples/src/main/scala/com/example/http4s/ExampleService.scala b/examples/src/main/scala/com/example/http4s/ExampleService.scala index e041a9d7f9d..8bd50f8c577 100644 --- a/examples/src/main/scala/com/example/http4s/ExampleService.scala +++ b/examples/src/main/scala/com/example/http4s/ExampleService.scala @@ -35,17 +35,16 @@ import org.http4s.server.middleware.authentication.BasicAuth.BasicAuthenticator import org.http4s._ import scala.concurrent.duration._ -class ExampleService[F[_]](blocker: Blocker)(implicit F: Effect[F], cs: ContextShift[F]) - extends Http4sDsl[F] { +class ExampleService[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { // A Router can mount multiple services to prefixes. The request is passed to the // service with the longest matching prefix. - def routes(implicit timer: Timer[F]): HttpRoutes[F] = + def routes: HttpRoutes[F] = Router[F]( "" -> rootRoutes, "/auth" -> authRoutes ) - def rootRoutes(implicit timer: Timer[F]): HttpRoutes[F] = + def rootRoutes: HttpRoutes[F] = HttpRoutes.of[F] { case GET -> Root => // disabled until twirl supports dotty @@ -82,7 +81,7 @@ class ExampleService[F[_]](blocker: Blocker)(implicit F: Effect[F], cs: ContextS // captures everything after "/static" into `path` // Try http://localhost:8080/http4s/static/nasa_blackhole_image.jpg // See also org.http4s.server.staticcontent to create a mountable service for static content - StaticFile.fromResource(path.toString, blocker, Some(req)).getOrElseF(NotFound()) + StaticFile.fromResource(path.toString, Some(req)).getOrElseF(NotFound()) /////////////////////////////////////////////////////////////// //////////////// Dealing with the message body //////////////// @@ -110,7 +109,7 @@ class ExampleService[F[_]](blocker: Blocker)(implicit F: Effect[F], cs: ContextS .decode[UrlForm] { data => data.values.get("sum").flatMap(_.uncons) match { case Some((s, _)) => - val sum = s.split(' ').filter(_.length > 0).map(_.trim.toInt).sum + val sum = s.split(' ').filter(_.nonEmpty).map(_.trim.toInt).sum Ok(sum.toString) case None => BadRequest(s"Invalid data: " + data) @@ -174,7 +173,7 @@ class ExampleService[F[_]](blocker: Blocker)(implicit F: Effect[F], cs: ContextS case req @ GET -> Root / "image.jpg" => StaticFile - .fromResource("/nasa_blackhole_image.jpg", blocker, Some(req)) + .fromResource("/nasa_blackhole_image.jpg", Some(req)) .getOrElseF(NotFound()) /////////////////////////////////////////////////////////////// @@ -193,11 +192,11 @@ class ExampleService[F[_]](blocker: Blocker)(implicit F: Effect[F], cs: ContextS def helloWorldService: F[Response[F]] = Ok("Hello World!") // This is a mock data source, but could be a Process representing results from a database - def dataStream(n: Int)(implicit timer: Timer[F]): Stream[F, String] = { + def dataStream(n: Int)(implicit clock: Clock[F]): Stream[F, String] = { val interval = 100.millis val stream = Stream .awakeEvery[F](interval) - .evalMap(_ => timer.clock.realTime(MILLISECONDS)) + .evalMap(_ => clock.realTime) .map(time => s"Current system time: $time ms\n") .take(n.toLong) @@ -225,6 +224,6 @@ class ExampleService[F[_]](blocker: Blocker)(implicit F: Effect[F], cs: ContextS } object ExampleService { - def apply[F[_]: Effect: ContextShift](blocker: Blocker): ExampleService[F] = - new ExampleService[F](blocker) + def apply[F[_]: Async]: ExampleService[F] = + new ExampleService[F] } diff --git a/examples/tomcat/src/main/scala/com/example/http4s/tomcat/TomcatExample.scala b/examples/tomcat/src/main/scala/com/example/http4s/tomcat/TomcatExample.scala index a038e912142..63f87619a7e 100644 --- a/examples/tomcat/src/main/scala/com/example/http4s/tomcat/TomcatExample.scala +++ b/examples/tomcat/src/main/scala/com/example/http4s/tomcat/TomcatExample.scala @@ -31,20 +31,17 @@ object TomcatExample extends IOApp { } object TomcatExampleApp { - def builder[F[_]: ConcurrentEffect: ContextShift: Timer](blocker: Blocker): TomcatBuilder[F] = { + def builder[F[_]: Async]: TomcatBuilder[F] = { val metricsRegistry: MetricRegistry = new MetricRegistry val metrics: HttpMiddleware[F] = Metrics[F](Dropwizard(metricsRegistry, "server")) TomcatBuilder[F] .bindHttp(8080) - .mountService(metrics(ExampleService[F](blocker).routes), "/http4s") + .mountService(metrics(ExampleService[F].routes), "/http4s") .mountService(metricsService(metricsRegistry), "/metrics/*") .mountFilter(NoneShallPass, "/black-knight/*") } - def resource[F[_]: ConcurrentEffect: ContextShift: Timer]: Resource[F, Server] = - for { - blocker <- Blocker[F] - server <- builder[F](blocker).resource - } yield server + def resource[F[_]: Async]: Resource[F, Server] = + builder[F].resource } diff --git a/examples/tomcat/src/main/scala/com/example/http4s/tomcat/TomcatSslExample.scala b/examples/tomcat/src/main/scala/com/example/http4s/tomcat/TomcatSslExample.scala index d14edb96b72..52835648810 100644 --- a/examples/tomcat/src/main/scala/com/example/http4s/tomcat/TomcatSslExample.scala +++ b/examples/tomcat/src/main/scala/com/example/http4s/tomcat/TomcatSslExample.scala @@ -27,15 +27,12 @@ object TomcatSslExample extends IOApp { } object TomcatSslExampleApp { - def builder[F[_]: ConcurrentEffect: ContextShift: Timer](blocker: Blocker): TomcatBuilder[F] = + def builder[F[_]: Async]: TomcatBuilder[F] = TomcatExampleApp - .builder[F](blocker) + .builder[F] .bindHttp(8443) .withSSL(ssl.storeInfo, ssl.keyManagerPassword) - def resource[F[_]: ConcurrentEffect: ContextShift: Timer]: Resource[F, Server] = - for { - blocker <- Blocker[F] - server <- builder[F](blocker).resource - } yield server + def resource[F[_]: Async]: Resource[F, Server] = + builder[F].resource } diff --git a/examples/war/src/main/scala/com/example/http4s/war/Bootstrap.scala b/examples/war/src/main/scala/com/example/http4s/war/Bootstrap.scala index 55e15a7e1e3..36d2f540232 100644 --- a/examples/war/src/main/scala/com/example/http4s/war/Bootstrap.scala +++ b/examples/war/src/main/scala/com/example/http4s/war/Bootstrap.scala @@ -17,22 +17,26 @@ package com.example.http4s package war -import cats.effect.{Blocker, ExitCode, IO, IOApp} +import cats.effect.std.Dispatcher +import cats.effect.{ExitCode, IO, IOApp} +import org.http4s.servlet.syntax._ + import javax.servlet.annotation.WebListener import javax.servlet.{ServletContextEvent, ServletContextListener} -import org.http4s.servlet.syntax._ -import scala.concurrent.ExecutionContext @WebListener -class Bootstrap extends ServletContextListener with IOApp { +class Bootstrap extends ServletContextListener { override def contextInitialized(sce: ServletContextEvent): Unit = { - val ctx = sce.getServletContext - val blocker = Blocker.liftExecutionContext(ExecutionContext.global) - ctx.mountService("example", ExampleService[IO](blocker).routes) - () + val app = new IOApp { + override def run(args: List[String]): IO[ExitCode] = Dispatcher[IO] + .use(dispatcher => + IO( + sce.getServletContext + .mountService("example", ExampleService[IO].routes, dispatcher = dispatcher))) + .as(ExitCode.Success) + } + app.main(Array.empty) } override def contextDestroyed(sce: ServletContextEvent): Unit = {} - - override def run(args: List[String]): IO[ExitCode] = ??? } diff --git a/jawn/src/main/scala/org/http4s/jawn/JawnInstances.scala b/jawn/src/main/scala/org/http4s/jawn/JawnInstances.scala index 82204d13f3b..89297f7cd6a 100644 --- a/jawn/src/main/scala/org/http4s/jawn/JawnInstances.scala +++ b/jawn/src/main/scala/org/http4s/jawn/JawnInstances.scala @@ -21,10 +21,10 @@ import cats.effect._ import cats.syntax.all._ import fs2.Stream import org.typelevel.jawn.{AsyncParser, Facade, ParseException} -import jawnfs2._ +import org.typelevel.jawn.fs2._ trait JawnInstances { - def jawnDecoder[F[_]: Sync, J: Facade]: EntityDecoder[F, J] = + def jawnDecoder[F[_]: Concurrent, J: Facade]: EntityDecoder[F, J] = EntityDecoder.decodeBy(MediaType.application.json)(jawnDecoderImpl[F, J]) protected def jawnParseExceptionMessage: ParseException => DecodeFailure = @@ -33,7 +33,8 @@ trait JawnInstances { JawnInstances.defaultJawnEmptyBodyMessage // some decoders may reuse it and avoid extra content negotiation - private[http4s] def jawnDecoderImpl[F[_]: Sync, J: Facade](m: Media[F]): DecodeResult[F, J] = + private[http4s] def jawnDecoderImpl[F[_]: Concurrent, J: Facade]( + m: Media[F]): DecodeResult[F, J] = DecodeResult { m.body.chunks .parseJson(AsyncParser.SingleValue) diff --git a/jetty-client/src/main/scala/org/http4s/jetty/client/JettyClient.scala b/jetty-client/src/main/scala/org/http4s/jetty/client/JettyClient.scala index 34eee927424..9481734871e 100644 --- a/jetty-client/src/main/scala/org/http4s/jetty/client/JettyClient.scala +++ b/jetty-client/src/main/scala/org/http4s/jetty/client/JettyClient.scala @@ -19,6 +19,7 @@ package jetty package client import cats.effect._ +import cats.effect.std.Dispatcher import cats.syntax.all._ import fs2._ import org.eclipse.jetty.client.HttpClient @@ -32,21 +33,25 @@ object JettyClient { private val logger: Logger = getLogger def allocate[F[_]](client: HttpClient = defaultHttpClient())(implicit - F: ConcurrentEffect[F]): F[(Client[F], F[Unit])] = { + F: Async[F]): F[(Client[F], F[Unit])] = resource(client).allocated + + def resource[F[_]](client: HttpClient = defaultHttpClient())(implicit + F: Async[F]): Resource[F, Client[F]] = Dispatcher[F].flatMap { implicit D => val acquire = F .pure(client) .flatTap(client => F.delay(client.start())) .map(client => Client[F] { req => - Resource.suspend(F.asyncF[Resource[F, Response[F]]] { cb => + Resource.suspend(F.async[Resource[F, Response[F]]] { cb => F.bracket(StreamRequestContentProvider()) { dcp => (for { jReq <- F.catchNonFatal(toJettyRequest(client, req, dcp)) rl <- ResponseListener(cb) _ <- F.delay(jReq.send(rl)) _ <- dcp.write(req) - } yield ()).recover { case e => + } yield Option.empty[F[Unit]]).recover { case e => cb(Left(e)) + Option.empty[F[Unit]] } } { dcp => F.delay(dcp.close()) @@ -56,15 +61,11 @@ object JettyClient { val dispose = F .delay(client.stop()) .handleErrorWith(t => F.delay(logger.error(t)("Unable to shut down Jetty client"))) - acquire.map((_, dispose)) + Resource.make(acquire)(_ => dispose) } - def resource[F[_]](client: HttpClient = defaultHttpClient())(implicit - F: ConcurrentEffect[F]): Resource[F, Client[F]] = - Resource(allocate[F](client)) - def stream[F[_]](client: HttpClient = defaultHttpClient())(implicit - F: ConcurrentEffect[F]): Stream[F, Client[F]] = + F: Async[F]): Stream[F, Client[F]] = Stream.resource(resource(client)) def defaultHttpClient(): HttpClient = { diff --git a/jetty-client/src/main/scala/org/http4s/jetty/client/ResponseListener.scala b/jetty-client/src/main/scala/org/http4s/jetty/client/ResponseListener.scala index e3f8f5c7dcd..8436aa2e8c2 100644 --- a/jetty-client/src/main/scala/org/http4s/jetty/client/ResponseListener.scala +++ b/jetty-client/src/main/scala/org/http4s/jetty/client/ResponseListener.scala @@ -19,23 +19,22 @@ package jetty package client import cats.effect._ -import cats.effect.implicits._ +import cats.effect.std.{Dispatcher, Queue} import cats.syntax.all._ import fs2._ import fs2.Stream._ -import fs2.concurrent.Queue import java.nio.ByteBuffer import org.eclipse.jetty.client.api.{Result, Response => JettyResponse} import org.eclipse.jetty.http.{HttpFields, HttpVersion => JHttpVersion} import org.eclipse.jetty.util.{Callback => JettyCallback} -import org.http4s.jetty.client.ResponseListener.Item -import org.http4s.internal.{invokeCallback, loggingAsyncCallback} import org.http4s.internal.CollectionCompat.CollectionConverters._ +import org.http4s.internal.loggingAsyncCallback +import org.http4s.jetty.client.ResponseListener.Item import org.log4s.getLogger private[jetty] final case class ResponseListener[F[_]]( - queue: Queue[F, Item], - cb: Callback[Resource[F, Response[F]]])(implicit F: ConcurrentEffect[F]) + queue: Queue[F, Option[Item]], + cb: Callback[Resource[F, Response[F]]])(implicit F: Async[F], D: Dispatcher[F]) extends JettyResponse.Listener.Adapter { import ResponseListener.logger @@ -51,7 +50,7 @@ private[jetty] final case class ResponseListener[F[_]]( status = s, httpVersion = getHttpVersion(response.getVersion), headers = getHeaders(response.getHeaders), - body = queue.dequeue.repeatPull { + body = Stream.fromQueueNoneTerminated(queue).repeatPull { _.uncons1.flatMap { case None => Pull.pure(None) case Some((Item.Done, _)) => Pull.pure(None) @@ -63,7 +62,7 @@ private[jetty] final case class ResponseListener[F[_]]( } .leftMap { t => abort(t, response); t } - invokeCallback(logger)(cb(r)) + D.unsafeRunAndForget(F.delay(cb(r)).attempt.flatMap(loggingAsyncCallback[F, Unit](logger))) } private def getHttpVersion(version: JHttpVersion): HttpVersion = @@ -84,15 +83,17 @@ private[jetty] final case class ResponseListener[F[_]]( val copy = ByteBuffer.allocate(content.remaining()) copy.put(content).flip() enqueue(Item.Buf(copy)) { - case Right(_) => IO(callback.succeeded()) + case Right(_) => F.delay(callback.succeeded()) case Left(e) => - IO(logger.error(e)("Error in asynchronous callback")) >> IO(callback.failed(e)) + F.delay(logger.error(e)("Error in asynchronous callback")) >> F.delay(callback.failed(e)) } } override def onFailure(response: JettyResponse, failure: Throwable): Unit = - if (responseSent) enqueue(Item.Raise(failure))(_ => IO.unit) - else invokeCallback(logger)(cb(Left(failure))) + if (responseSent) enqueue(Item.Raise(failure))(_ => F.unit) + else + D.unsafeRunAndForget( + F.delay(cb(Left(failure))).attempt.flatMap(loggingAsyncCallback[F, Unit](logger))) // the entire response has been received override def onSuccess(response: JettyResponse): Unit = @@ -109,10 +110,10 @@ private[jetty] final case class ResponseListener[F[_]]( closeStream() private def closeStream(): Unit = - enqueue(Item.Done)(loggingAsyncCallback(logger)) + enqueue(Item.Done)(loggingAsyncCallback[F, Unit](logger)) - private def enqueue(item: Item)(cb: Either[Throwable, Unit] => IO[Unit]): Unit = - queue.enqueue1(item).runAsync(cb).unsafeRunSync() + private def enqueue(item: Item)(cb: Either[Throwable, Unit] => F[Unit]): Unit = + D.unsafeRunAndForget(queue.offer(item.some).attempt.flatMap(cb)) } private[jetty] object ResponseListener { @@ -126,8 +127,9 @@ private[jetty] object ResponseListener { private val logger = getLogger def apply[F[_]](cb: Callback[Resource[F, Response[F]]])(implicit - F: ConcurrentEffect[F]): F[ResponseListener[F]] = + F: Async[F], + D: Dispatcher[F]): F[ResponseListener[F]] = Queue - .synchronous[F, Item] + .synchronous[F, Option[Item]] .map(q => ResponseListener(q, cb)) } diff --git a/jetty-client/src/main/scala/org/http4s/jetty/client/StreamRequestContentProvider.scala b/jetty-client/src/main/scala/org/http4s/jetty/client/StreamRequestContentProvider.scala index fc8d2ddda10..090882fa0c8 100644 --- a/jetty-client/src/main/scala/org/http4s/jetty/client/StreamRequestContentProvider.scala +++ b/jetty-client/src/main/scala/org/http4s/jetty/client/StreamRequestContentProvider.scala @@ -19,8 +19,7 @@ package jetty package client import cats.effect._ -import cats.effect.concurrent.Semaphore -import cats.effect.implicits._ +import cats.effect.std._ import cats.syntax.all._ import fs2._ import org.eclipse.jetty.client.util.DeferredContentProvider @@ -29,7 +28,8 @@ import org.http4s.internal.loggingAsyncCallback import org.log4s.getLogger private[jetty] final case class StreamRequestContentProvider[F[_]](s: Semaphore[F])(implicit - F: Effect[F]) + F: Async[F], + D: Dispatcher[F]) extends DeferredContentProvider { import StreamRequestContentProvider.logger @@ -53,13 +53,13 @@ private[jetty] final case class StreamRequestContentProvider[F[_]](s: Semaphore[ private val callback: JettyCallback = new JettyCallback { override def succeeded(): Unit = - s.release.runAsync(loggingAsyncCallback(logger)).unsafeRunSync() + D.unsafeRunAndForget(s.release.attempt.flatMap(loggingAsyncCallback[F, Unit](logger))) } } private[jetty] object StreamRequestContentProvider { private val logger = getLogger - def apply[F[_]]()(implicit F: ConcurrentEffect[F]): F[StreamRequestContentProvider[F]] = + def apply[F[_]: Async: Dispatcher](): F[StreamRequestContentProvider[F]] = Semaphore[F](1).map(StreamRequestContentProvider(_)) } diff --git a/jetty-server/src/main/scala/org/http4s/jetty/server/JettyBuilder.scala b/jetty-server/src/main/scala/org/http4s/jetty/server/JettyBuilder.scala index 3ea57466972..f63f8dcd4ef 100644 --- a/jetty-server/src/main/scala/org/http4s/jetty/server/JettyBuilder.scala +++ b/jetty-server/src/main/scala/org/http4s/jetty/server/JettyBuilder.scala @@ -19,6 +19,8 @@ package jetty package server import cats.effect._ +import cats.effect.kernel.Async +import cats.effect.std.Dispatcher import cats.syntax.all._ import java.net.InetSocketAddress import java.util @@ -66,7 +68,7 @@ sealed class JettyBuilder[F[_]] private ( supportHttp2: Boolean, banner: immutable.Seq[String], jettyHttpConfiguration: HttpConfiguration -)(implicit protected val F: ConcurrentEffect[F]) +)(implicit protected val F: Async[F]) extends ServletContainer[F] with ServerBuilder[F] { type Self = JettyBuilder[F] @@ -171,7 +173,7 @@ sealed class JettyBuilder[F[_]] private ( servlet: HttpServlet, urlMapping: String, name: Option[String] = None): Self = - copy(mounts = mounts :+ Mount[F] { (context, index, _) => + copy(mounts = mounts :+ Mount[F] { (context, index, _, _) => val servletName = name.getOrElse(s"servlet-$index") context.addServlet(new ServletHolder(servletName, servlet), urlMapping) }) @@ -182,7 +184,7 @@ sealed class JettyBuilder[F[_]] private ( name: Option[String], dispatches: util.EnumSet[DispatcherType] ): Self = - copy(mounts = mounts :+ Mount[F] { (context, index, _) => + copy(mounts = mounts :+ Mount[F] { (context, index, _, _) => val filterName = name.getOrElse(s"filter-$index") val filterHolder = new FilterHolder(filter) filterHolder.setName(filterName) @@ -193,12 +195,13 @@ sealed class JettyBuilder[F[_]] private ( mountHttpApp(service.orNotFound, prefix) def mountHttpApp(service: HttpApp[F], prefix: String): Self = - copy(mounts = mounts :+ Mount[F] { (context, index, builder) => + copy(mounts = mounts :+ Mount[F] { (context, index, builder, dispatcher) => val servlet = new AsyncHttp4sServlet( service = service, asyncTimeout = builder.asyncTimeout, servletIo = builder.servletIo, - serviceErrorHandler = builder.serviceErrorHandler + serviceErrorHandler = builder.serviceErrorHandler, + dispatcher ) val servletName = s"servlet-$index" val urlMapping = ServletContainer.prefixMapping(prefix) @@ -266,10 +269,8 @@ sealed class JettyBuilder[F[_]] private ( // If threadPoolResourceOption is None, then use the value of // threadPool. val threadPoolR: Resource[F, ThreadPool] = - threadPoolResourceOption.getOrElse( - Resource.pure(threadPool) - ) - val serverR: ThreadPool => Resource[F, Server] = (threadPool: ThreadPool) => + threadPoolResourceOption.getOrElse(Resource.pure(threadPool)) + val serverR = (threadPool: ThreadPool, dispatcher: Dispatcher[F]) => JettyLifeCycle .lifeCycleAsResource[F, JServer]( F.delay { @@ -298,7 +299,7 @@ sealed class JettyBuilder[F[_]] private ( }) for ((mount, i) <- mounts.zipWithIndex) - mount.f(context, i, this) + mount.f(context, i, this, dispatcher) jetty } @@ -314,17 +315,19 @@ sealed class JettyBuilder[F[_]] private ( lazy val isSecure: Boolean = sslConfig.isSecure }) for { + dispatcher <- Dispatcher[F] threadPool <- threadPoolR - server <- serverR(threadPool) + server <- serverR(threadPool, dispatcher) _ <- Resource.eval(banner.traverse_(value => F.delay(logger.info(value)))) _ <- Resource.eval(F.delay(logger.info( - s"http4s v${BuildInfo.version} on Jetty v${JServer.getVersion} started at ${server.baseUri}"))) + s"http4s v${BuildInfo.version} on Jetty v${JServer.getVersion} started at ${server.baseUri}" + ))) } yield server } } object JettyBuilder { - def apply[F[_]: ConcurrentEffect] = + def apply[F[_]: Async] = new JettyBuilder[F]( socketAddress = defaults.IPv4SocketAddress, threadPool = LazyThreadPool.newLazyThreadPool, @@ -431,4 +434,5 @@ object JettyBuilder { } } -private final case class Mount[F[_]](f: (ServletContextHandler, Int, JettyBuilder[F]) => Unit) +private final case class Mount[F[_]]( + f: (ServletContextHandler, Int, JettyBuilder[F], Dispatcher[F]) => Unit) diff --git a/jetty-server/src/main/scala/org/http4s/jetty/server/JettyLifeCycle.scala b/jetty-server/src/main/scala/org/http4s/jetty/server/JettyLifeCycle.scala index 47eb59a76df..372c9546f89 100644 --- a/jetty-server/src/main/scala/org/http4s/jetty/server/JettyLifeCycle.scala +++ b/jetty-server/src/main/scala/org/http4s/jetty/server/JettyLifeCycle.scala @@ -56,7 +56,7 @@ private[jetty] object JettyLifeCycle { * internally, e.g. due to some internal error occurring. */ private[this] def stopLifeCycle[F[_]](lifeCycle: LifeCycle)(implicit F: Async[F]): F[Unit] = - F.async[Unit] { cb => + F.async_[Unit] { cb => lifeCycle.addLifeCycleListener( new AbstractLifeCycleListener { override def lifeCycleStopped(a: LifeCycle): Unit = @@ -96,7 +96,7 @@ private[jetty] object JettyLifeCycle { * (or starting) this will fail. */ private[this] def startLifeCycle[F[_]](lifeCycle: LifeCycle)(implicit F: Async[F]): F[Unit] = - F.async[Unit] { cb => + F.async_[Unit] { cb => lifeCycle.addLifeCycleListener( new AbstractLifeCycleListener { override def lifeCycleStarted(a: LifeCycle): Unit = diff --git a/jetty-server/src/test/scala/org/http4s/jetty/server/Issue454.scala b/jetty-server/src/test/scala/org/http4s/jetty/server/Issue454.scala deleted file mode 100644 index 875f345f635..00000000000 --- a/jetty-server/src/test/scala/org/http4s/jetty/server/Issue454.scala +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2014 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 jetty -package server - -import cats.effect.{ContextShift, IO} -import org.eclipse.jetty.server.{HttpConfiguration, HttpConnectionFactory, Server, ServerConnector} -import org.eclipse.jetty.servlet.{ServletContextHandler, ServletHolder} -import org.http4s.dsl.io._ -import org.http4s.server.DefaultServiceErrorHandler -import org.http4s.servlet.AsyncHttp4sServlet -import org.http4s.syntax.all._ - -object Issue454 { - implicit val cs: ContextShift[IO] = Http4sSuite.TestContextShift - - // If the bug is not triggered right away, try increasing or - // repeating the request. Also if you decrease the data size (to - // say 32mb, the bug does not manifest so often, but the stack - // trace is a bit different. - val insanelyHugeData = Array.ofDim[Byte](1024 * 1024 * 128) - - { - var i = 0 - while (i < insanelyHugeData.length) { - insanelyHugeData(i) = ('0' + i).toByte - i = i + 1 - } - insanelyHugeData(insanelyHugeData.length - 1) = '-' // end marker - } - - def main(args: Array[String]): Unit = { - val server = new Server - - val connector = new ServerConnector(server, new HttpConnectionFactory(new HttpConfiguration())) - connector.setPort(5555) - - val context = new ServletContextHandler - context.setContextPath("/") - context.addServlet(new ServletHolder(servlet), "/") - - server.addConnector(connector) - server.setHandler(context) - - server.start() - } - - val servlet = new AsyncHttp4sServlet[IO]( - service = HttpRoutes - .of[IO] { case GET -> Root => - Ok(insanelyHugeData) - } - .orNotFound, - servletIo = org.http4s.servlet.NonBlockingServletIo(4096), - serviceErrorHandler = DefaultServiceErrorHandler - ) -} diff --git a/jetty-server/src/test/scala/org/http4s/jetty/server/JettyServerSuite.scala b/jetty-server/src/test/scala/org/http4s/jetty/server/JettyServerSuite.scala index 0fa7a85f894..be6bf2c1196 100644 --- a/jetty-server/src/test/scala/org/http4s/jetty/server/JettyServerSuite.scala +++ b/jetty-server/src/test/scala/org/http4s/jetty/server/JettyServerSuite.scala @@ -18,7 +18,7 @@ package org.http4s package jetty package server -import cats.effect.{ContextShift, IO, Timer} +import cats.effect.{IO, Temporal} import cats.syntax.all._ import java.net.{HttpURLConnection, URL} import java.io.IOException @@ -29,7 +29,6 @@ import scala.concurrent.duration._ import scala.io.Source class JettyServerSuite extends Http4sSuite { - implicit val contextShift: ContextShift[IO] = Http4sSuite.TestContextShift def builder = JettyBuilder[IO] @@ -53,7 +52,7 @@ class JettyServerSuite extends Http4sSuite { IO.never case GET -> Root / "slow" => - implicitly[Timer[IO]].sleep(50.millis) *> Ok("slow") + Temporal[IO].sleep(50.millis) *> Ok("slow") }, "/" ) @@ -62,15 +61,14 @@ class JettyServerSuite extends Http4sSuite { val jettyServer = ResourceFixture[Server](serverR) def get(server: Server, path: String): IO[String] = - testBlocker.blockOn( - IO( - Source - .fromURL(new URL(s"http://127.0.0.1:${server.address.getPort}$path")) - .getLines() - .mkString)) + IO.blocking( + Source + .fromURL(new URL(s"http://127.0.0.1:${server.address.getPort}$path")) + .getLines() + .mkString) def post(server: Server, path: String, body: String): IO[String] = - testBlocker.blockOn(IO { + IO.blocking { val url = new URL(s"http://127.0.0.1:${server.address.getPort}$path") val conn = url.openConnection().asInstanceOf[HttpURLConnection] val bytes = body.getBytes(StandardCharsets.UTF_8) @@ -79,16 +77,15 @@ class JettyServerSuite extends Http4sSuite { conn.setDoOutput(true) conn.getOutputStream.write(bytes) Source.fromInputStream(conn.getInputStream, StandardCharsets.UTF_8.name).getLines().mkString - }) + } - jettyServer.test("ChannelOptions should should route requests on the service executor") { - server => - get(server, "/thread/routing").map(_.startsWith("http4s-suite-")).assert + jettyServer.test("ChannelOptions should route requests on the service executor") { server => + get(server, "/thread/routing").map(_.startsWith("http4s-suite-")).assert } - jettyServer.test( - "ChannelOptions should should execute the service task on the service executor") { server => - get(server, "/thread/effect").map(_.startsWith("http4s-suite-")).assert + jettyServer.test("ChannelOptions should execute the service task on the service executor") { + server => + get(server, "/thread/effect").map(_.startsWith("http4s-suite-")).assert } jettyServer.test("ChannelOptions should be able to echo its input") { server => @@ -96,7 +93,7 @@ class JettyServerSuite extends Http4sSuite { post(server, "/echo", input).map(_.startsWith(input)).assert } - jettyServer.test("Timeout not fire prematurely") { server => + jettyServer.test("Timeout should not fire prematurely") { server => get(server, "/slow").assertEquals("slow") } diff --git a/laws/src/main/scala/org/http4s/laws/EntityCodecLaws.scala b/laws/src/main/scala/org/http4s/laws/EntityCodecLaws.scala index 2570b9c2f5b..b25440c0eda 100644 --- a/laws/src/main/scala/org/http4s/laws/EntityCodecLaws.scala +++ b/laws/src/main/scala/org/http4s/laws/EntityCodecLaws.scala @@ -19,25 +19,24 @@ package laws import cats.syntax.all._ import cats.effect._ -import cats.effect.implicits._ import cats.laws._ trait EntityCodecLaws[F[_], A] extends EntityEncoderLaws[F, A] { - implicit def F: Effect[F] + implicit def F: Concurrent[F] implicit def encoder: EntityEncoder[F, A] implicit def decoder: EntityDecoder[F, A] - def entityCodecRoundTrip(a: A): IsEq[IO[Either[DecodeFailure, A]]] = + def entityCodecRoundTrip(a: A): IsEq[F[Either[DecodeFailure, A]]] = (for { - entity <- F.delay(encoder.toEntity(a)) + entity <- F.pure(encoder.toEntity(a)) message = Request(body = entity.body, headers = encoder.headers) a0 <- decoder.decode(message, strict = true).value - } yield a0).toIO <-> IO.pure(Right(a)) + } yield a0) <-> F.pure(Right(a)) } object EntityCodecLaws { def apply[F[_], A](implicit - F0: Effect[F], + F0: Concurrent[F], entityEncoderFA: EntityEncoder[F, A], entityDecoderFA: EntityDecoder[F, A]): EntityCodecLaws[F, A] = new EntityCodecLaws[F, A] { diff --git a/laws/src/main/scala/org/http4s/laws/EntityEncoderLaws.scala b/laws/src/main/scala/org/http4s/laws/EntityEncoderLaws.scala index 51d6d8e295c..4740d63213a 100644 --- a/laws/src/main/scala/org/http4s/laws/EntityEncoderLaws.scala +++ b/laws/src/main/scala/org/http4s/laws/EntityEncoderLaws.scala @@ -23,7 +23,7 @@ import cats.laws._ import org.http4s.headers.{`Content-Length`, `Transfer-Encoding`} trait EntityEncoderLaws[F[_], A] { - implicit def F: Effect[F] + implicit def F: Concurrent[F] implicit def encoder: EntityEncoder[F, A] @@ -44,7 +44,7 @@ trait EntityEncoderLaws[F[_], A] { object EntityEncoderLaws { def apply[F[_], A](implicit - F0: Effect[F], + F0: Concurrent[F], entityEncoderFA: EntityEncoder[F, A] ): EntityEncoderLaws[F, A] = new EntityEncoderLaws[F, A] { diff --git a/laws/src/main/scala/org/http4s/laws/discipline/ArbitraryInstances.scala b/laws/src/main/scala/org/http4s/laws/discipline/ArbitraryInstances.scala index 81ea1b55291..e5c7af94a43 100644 --- a/laws/src/main/scala/org/http4s/laws/discipline/ArbitraryInstances.scala +++ b/laws/src/main/scala/org/http4s/laws/discipline/ArbitraryInstances.scala @@ -21,11 +21,11 @@ package discipline import cats._ import cats.data.{Chain, NonEmptyList} import cats.laws.discipline.arbitrary.catsLawsArbitraryForChain -import cats.effect.{Effect, IO} -import cats.effect.laws.discipline.arbitrary._ -import cats.effect.laws.util.TestContext -import cats.syntax.all._ +import cats.effect.Concurrent +import cats.effect.testkit._ +import cats.effect.std.Dispatcher import cats.instances.order._ +import cats.syntax.all._ import com.comcast.ip4s import com.comcast.ip4s.Arbitraries._ import fs2.{Pure, Stream} @@ -795,14 +795,13 @@ private[http4s] trait ArbitraryInstances { } } - implicit def http4sTestingCogenForEntityBody[F[_]](implicit F: Effect[F]): Cogen[EntityBody[F]] = - catsEffectLawsCogenForIO[Vector[Byte]].contramap { stream => - var bytes: Vector[Byte] = null - val readBytes = IO(bytes) - F.runAsync(stream.compile.toVector) { - case Right(bs) => IO { bytes = bs } - case Left(t) => IO.raiseError(t) - }.toIO *> readBytes + implicit def http4sTestingCogenForEntityBody[F[_]](implicit + F: Concurrent[F], + dispatcher: Dispatcher[F], + testContext: TestContext + ): Cogen[EntityBody[F]] = + cogenFuture[Vector[Byte]].contramap { stream => + dispatcher.unsafeToFuture(stream.compile.toVector) } implicit def http4sTestingArbitraryForEntity[F[_]]: Arbitrary[Entity[F]] = @@ -813,7 +812,11 @@ private[http4s] trait ArbitraryInstances { } yield Entity(body.covary[F], length) }) - implicit def http4sTestingCogenForEntity[F[_]](implicit F: Effect[F]): Cogen[Entity[F]] = + implicit def http4sTestingCogenForEntity[F[_]](implicit + F: Concurrent[F], + dispatcher: Dispatcher[F], + testContext: TestContext + ): Cogen[Entity[F]] = Cogen[(EntityBody[F], Option[Long])].contramap(entity => (entity.body, entity.length)) implicit def http4sTestingArbitraryForEntityEncoder[F[_], A](implicit @@ -824,7 +827,9 @@ private[http4s] trait ArbitraryInstances { } yield EntityEncoder.encodeBy(hs)(f)) implicit def http4sTestingArbitraryForEntityDecoder[F[_], A](implicit - F: Effect[F], + F: Concurrent[F], + dispatcher: Dispatcher[F], + testContext: TestContext, g: Arbitrary[DecodeResult[F, A]]): Arbitrary[EntityDecoder[F, A]] = Arbitrary(for { f <- getArbitrary[(Media[F], Boolean) => DecodeResult[F, A]] @@ -834,10 +839,18 @@ private[http4s] trait ArbitraryInstances { def consumes = mrs }) - implicit def http4sTestingCogenForMedia[F[_]](implicit F: Effect[F]): Cogen[Media[F]] = + implicit def http4sTestingCogenForMedia[F[_]](implicit + F: Concurrent[F], + dispatcher: Dispatcher[F], + testContext: TestContext + ): Cogen[Media[F]] = Cogen[(Headers, EntityBody[F])].contramap(m => (m.headers, m.body)) - implicit def http4sTestingCogenForMessage[F[_]](implicit F: Effect[F]): Cogen[Message[F]] = + implicit def http4sTestingCogenForMessage[F[_]](implicit + F: Concurrent[F], + dispatcher: Dispatcher[F], + testContext: TestContext + ): Cogen[Message[F]] = Cogen[(Headers, EntityBody[F])].contramap(m => (m.headers, m.body)) implicit def http4sTestingCogenForHeaders: Cogen[Headers] = diff --git a/laws/src/main/scala/org/http4s/laws/discipline/EntityCodecTests.scala b/laws/src/main/scala/org/http4s/laws/discipline/EntityCodecTests.scala index 88994b27145..dbf14338384 100644 --- a/laws/src/main/scala/org/http4s/laws/discipline/EntityCodecTests.scala +++ b/laws/src/main/scala/org/http4s/laws/discipline/EntityCodecTests.scala @@ -30,14 +30,15 @@ trait EntityCodecTests[F[_], A] extends EntityEncoderTests[F, A] { arbitraryA: Arbitrary[A], shrinkA: Shrink[A], eqA: Eq[A] - ): List[(String, PropF[IO])] = + ): List[(String, PropF[F])] = { + implicit val F: Concurrent[F] = laws.F LawAdapter.isEqPropF("roundTrip", laws.entityCodecRoundTrip _) :: entityEncoderF - + } } object EntityCodecTests { def apply[F[_], A](implicit - effectF: Effect[F], + F: Concurrent[F], entityEncoderFA: EntityEncoder[F, A], entityDecoderFA: EntityDecoder[F, A] ): EntityCodecTests[F, A] = diff --git a/laws/src/main/scala/org/http4s/laws/discipline/EntityEncoderTests.scala b/laws/src/main/scala/org/http4s/laws/discipline/EntityEncoderTests.scala index 9feae538903..0c47201088c 100644 --- a/laws/src/main/scala/org/http4s/laws/discipline/EntityEncoderTests.scala +++ b/laws/src/main/scala/org/http4s/laws/discipline/EntityEncoderTests.scala @@ -18,7 +18,7 @@ package org.http4s package laws package discipline -import cats.Eq +import cats.{Eq, MonadThrow} import cats.effect._ import cats.laws.discipline._ import org.scalacheck.Arbitrary @@ -48,8 +48,8 @@ trait EntityEncoderTests[F[_], A] extends Laws { def entityEncoderF(implicit arbitraryA: Arbitrary[A], shrinkA: Shrink[A] - ): List[(String, PropF[IO])] = { - implicit val effectF = laws.F + ): List[(String, PropF[F])] = { + implicit val F: MonadThrow[F] = laws.F List( LawAdapter.isEqPropF("accurateContentLength", laws.accurateContentLengthIfDefined _), LawAdapter.booleanPropF( @@ -64,7 +64,7 @@ trait EntityEncoderTests[F[_], A] extends Laws { object EntityEncoderTests { def apply[F[_], A](implicit - effectF: Effect[F], + F0: Concurrent[F], entityEncoderFA: EntityEncoder[F, A] ): EntityEncoderTests[F, A] = new EntityEncoderTests[F, A] { diff --git a/laws/src/main/scala/org/http4s/laws/discipline/LawAdapter.scala b/laws/src/main/scala/org/http4s/laws/discipline/LawAdapter.scala index 207e6f5fe18..04080b0b192 100644 --- a/laws/src/main/scala/org/http4s/laws/discipline/LawAdapter.scala +++ b/laws/src/main/scala/org/http4s/laws/discipline/LawAdapter.scala @@ -16,9 +16,7 @@ package org.http4s.laws.discipline -import cats.Eq -import cats.effect._ -import cats.effect.implicits._ +import cats.{Eq, MonadThrow} import cats.laws.IsEq import cats.syntax.all._ import org.scalacheck.Arbitrary @@ -29,16 +27,15 @@ import munit.CatsEffectAssertions._ trait LawAdapter { - def booleanPropF[F[_]: Effect, A](propLabel: String, prop: => Boolean): (String, PropF[F]) = + def booleanPropF[F[_]: MonadThrow, A](propLabel: String, prop: => Boolean): (String, PropF[F]) = propLabel -> PropF.boolean(prop) - def isEqPropF[F[_]: Effect, A: Arbitrary: Shrink, B: Eq]( - propLabel: String, - prop: A => IsEq[F[B]]): (String, PropF[IO]) = + def isEqPropF[F[_], A: Arbitrary: Shrink, B: Eq](propLabel: String, prop: A => IsEq[F[B]])( + implicit F: MonadThrow[F]): (String, PropF[F]) = propLabel -> PropF .forAllF { (a: A) => val isEq = prop(a) - (isEq.lhs, isEq.rhs).mapN(_ === _).toIO.assert + (isEq.lhs, isEq.rhs).mapN(_ === _).flatMap(b => F.catchOnly[AssertionError](assert(b))) } .map(p => p.copy(labels = p.labels + propLabel)) diff --git a/okhttp-client/src/main/scala/org/http4s/okhttp/client/OkHttpBuilder.scala b/okhttp-client/src/main/scala/org/http4s/okhttp/client/OkHttpBuilder.scala index 2c79c4474d7..f6d0c9fbaa4 100644 --- a/okhttp-client/src/main/scala/org/http4s/okhttp/client/OkHttpBuilder.scala +++ b/okhttp-client/src/main/scala/org/http4s/okhttp/client/OkHttpBuilder.scala @@ -21,7 +21,6 @@ import java.io.IOException import cats.effect._ import cats.syntax.all._ -import cats.effect.implicits._ import fs2.io._ import okhttp3.{ Call, @@ -36,16 +35,14 @@ import okhttp3.{ } import okio.BufferedSink import org.http4s.client.Client -import org.http4s.internal.{BackendBuilder, invokeCallback} +import org.http4s.internal.BackendBuilder import org.http4s.internal.CollectionCompat.CollectionConverters._ import org.log4s.getLogger -import scala.concurrent.ExecutionContext import scala.util.control.NonFatal +import cats.effect.std.Dispatcher +import OkHttpBuilder._ /** A builder for [[org.http4s.client.Client]] with an OkHttp backend. - * - * @define BLOCKINGEC an execution context onto which all blocking - * I/O operations will be shifted. * * @define WHYNOSHUTDOWN It is assumed that the OkHttp client is * passed to us as a Resource, or that the caller will shut it down, or @@ -53,51 +50,43 @@ import scala.util.control.NonFatal * their own. * * @param okHttpClient the underlying OkHttp client. - * @param blockingExecutionContext $BLOCKINGEC */ sealed abstract class OkHttpBuilder[F[_]] private ( - val okHttpClient: OkHttpClient, - val blocker: Blocker -)(implicit protected val F: ConcurrentEffect[F], cs: ContextShift[F]) + val okHttpClient: OkHttpClient +)(implicit protected val F: Async[F]) extends BackendBuilder[F, Client[F]] { - private[this] val logger = getLogger - private def copy( - okHttpClient: OkHttpClient = okHttpClient, - blocker: Blocker = blocker - ) = new OkHttpBuilder[F](okHttpClient, blocker) {} + private def invokeCallback(result: Result[F], cb: Result[F] => Unit, dispatcher: Dispatcher[F])( + implicit F: Async[F]): Unit = { + val f = logTap(result).flatMap(r => F.delay(cb(r))) + dispatcher.unsafeRunSync(f) + () + } + + private def copy(okHttpClient: OkHttpClient) = new OkHttpBuilder[F](okHttpClient) {} def withOkHttpClient(okHttpClient: OkHttpClient): OkHttpBuilder[F] = copy(okHttpClient = okHttpClient) - def withBlocker(blocker: Blocker): OkHttpBuilder[F] = - copy(blocker = blocker) - - @deprecated("Use withBlocker instead", "0.21.0") - def withBlockingExecutionContext(blockingExecutionContext: ExecutionContext): OkHttpBuilder[F] = - copy(blocker = Blocker.liftExecutionContext(blockingExecutionContext)) - /** Creates the [[org.http4s.client.Client]] * * The shutdown method on this client is a no-op. $WHYNOSHUTDOWN */ - def create: Client[F] = Client(run) + private def create(dispatcher: Dispatcher[F]): Client[F] = Client(run(dispatcher)) def resource: Resource[F, Client[F]] = - Resource.make(F.delay(create))(_ => F.unit) + Dispatcher[F].flatMap(dispatcher => Resource.make(F.delay(create(dispatcher)))(_ => F.unit)) - private def run(req: Request[F]) = - Resource.suspend(F.async[Resource[F, Response[F]]] { cb => - okHttpClient.newCall(toOkHttpRequest(req)).enqueue(handler(cb)) - () + private def run(dispatcher: Dispatcher[F])(req: Request[F]) = + Resource.suspend(F.async_[Resource[F, Response[F]]] { cb => + okHttpClient.newCall(toOkHttpRequest(req, dispatcher)).enqueue(handler(cb, dispatcher)) }) - private def handler(cb: Either[Throwable, Resource[F, Response[F]]] => Unit)(implicit - F: ConcurrentEffect[F], - cs: ContextShift[F]): Callback = + private def handler(cb: Result[F] => Unit, dispatcher: Dispatcher[F])(implicit + F: Async[F]): Callback = new Callback { override def onFailure(call: Call, e: IOException): Unit = - invokeCallback(logger)(cb(Left(e))) + invokeCallback(Left(e), cb, dispatcher) override def onResponse(call: Call, response: OKResponse): Unit = { val protocol = response.protocol() match { @@ -108,7 +97,7 @@ sealed abstract class OkHttpBuilder[F[_]] private ( } val status = Status.fromInt(response.code()) val bodyStream = response.body.byteStream() - val body = readInputStream(F.pure(bodyStream), 1024, blocker, false) + val body = readInputStream(F.pure(bodyStream), 1024, false) val dispose = F.delay { bodyStream.close() () @@ -132,7 +121,7 @@ sealed abstract class OkHttpBuilder[F[_]] private ( bodyStream.close() t } - invokeCallback(logger)(cb(r)) + invokeCallback(r, cb, dispatcher) } } @@ -141,7 +130,8 @@ sealed abstract class OkHttpBuilder[F[_]] private ( response.headers().values(k).asScala.map(k -> _) }) - private def toOkHttpRequest(req: Request[F])(implicit F: Effect[F]): OKRequest = { + private def toOkHttpRequest(req: Request[F], dispatcher: Dispatcher[F])(implicit + F: Async[F]): OKRequest = { val body = req match { case _ if req.isChunked || req.contentLength.isDefined => new RequestBody { @@ -151,8 +141,10 @@ sealed abstract class OkHttpBuilder[F[_]] private ( //OKHttp will override the content-length header set below and always use "transfer-encoding: chunked" unless this method is overriden override def contentLength(): Long = req.contentLength.getOrElse(-1L) - override def writeTo(sink: BufferedSink): Unit = - req.body.chunks + override def writeTo(sink: BufferedSink): Unit = { + // This has to be synchronous with this method, or else + // chunks get silently dropped. + val f = req.body.chunks .map(_.toArray) .evalMap { (b: Array[Byte]) => F.delay { @@ -161,10 +153,9 @@ sealed abstract class OkHttpBuilder[F[_]] private ( } .compile .drain - // This has to be synchronous with this method, or else - // chunks get silently dropped. - .toIO - .unsafeRunSync() + dispatcher.unsafeRunSync(f) + () + } } // if it's a GET or HEAD, okhttp wants us to pass null case _ if req.method == Method.GET || req.method == Method.HEAD => null @@ -185,9 +176,6 @@ sealed abstract class OkHttpBuilder[F[_]] private ( } /** Builder for a [[org.http4s.client.Client]] with an OkHttp backend - * - * @define BLOCKER a [[cats.effect.Blocker]] onto which all blocking - * I/O operations will be shifted. */ object OkHttpBuilder { private[this] val logger = getLogger @@ -195,28 +183,21 @@ object OkHttpBuilder { /** Creates a builder. * * @param okHttpClient the underlying client. - * @param blocker $BLOCKER */ - def apply[F[_]: ConcurrentEffect: ContextShift]( - okHttpClient: OkHttpClient, - blocker: Blocker): OkHttpBuilder[F] = - new OkHttpBuilder[F](okHttpClient, blocker) {} + def apply[F[_]: Async](okHttpClient: OkHttpClient): OkHttpBuilder[F] = + new OkHttpBuilder[F](okHttpClient) {} /** Create a builder with a default OkHttp client. The builder is * returned as a `Resource` so we shut down the OkHttp client that * we create. - * - * @param blocker $BLOCKER */ - def withDefaultClient[F[_]: ConcurrentEffect: ContextShift]( - blocker: Blocker): Resource[F, OkHttpBuilder[F]] = - defaultOkHttpClient.map(apply(_, blocker)) + def withDefaultClient[F[_]: Async]: Resource[F, OkHttpBuilder[F]] = + defaultOkHttpClient.map(apply(_)) - private def defaultOkHttpClient[F[_]](implicit - F: ConcurrentEffect[F]): Resource[F, OkHttpClient] = + private def defaultOkHttpClient[F[_]](implicit F: Async[F]): Resource[F, OkHttpClient] = Resource.make(F.delay(new OkHttpClient()))(shutdown(_)) - private def shutdown[F[_]](client: OkHttpClient)(implicit F: Sync[F]) = + private def shutdown[F[_]](client: OkHttpClient)(implicit F: Async[F]) = F.delay { try client.dispatcher.executorService().shutdown() catch { @@ -235,4 +216,13 @@ object OkHttpBuilder { logger.warn(t)("Unable to close cache when disposing of OkHttp client") } } + + private type Result[F[_]] = Either[Throwable, Resource[F, Response[F]]] + + private def logTap[F[_]](result: Result[F])(implicit + F: Async[F]): F[Either[Throwable, Resource[F, Response[F]]]] = + (result match { + case Left(e) => F.delay(logger.error(e)("Error in call back")) + case Right(_) => F.unit + }).map(_ => result) } diff --git a/okhttp-client/src/test/scala/org/http4s/okhttp/client/OkHttpClientSuite.scala b/okhttp-client/src/test/scala/org/http4s/okhttp/client/OkHttpClientSuite.scala index 02f776f64c5..db425a32f27 100644 --- a/okhttp-client/src/test/scala/org/http4s/okhttp/client/OkHttpClientSuite.scala +++ b/okhttp-client/src/test/scala/org/http4s/okhttp/client/OkHttpClientSuite.scala @@ -22,6 +22,5 @@ import cats.effect.IO import org.http4s.client.ClientRouteTestBattery class OkHttpClientSuite extends ClientRouteTestBattery("OkHttp") { - def clientResource = - OkHttpBuilder.withDefaultClient[IO](testBlocker).map(_.create) + def clientResource = OkHttpBuilder.withDefaultClient[IO].flatMap(_.resource) } diff --git a/play-json/src/main/scala/org/http4s/play/PlayEntityDecoder.scala b/play-json/src/main/scala/org/http4s/play/PlayEntityDecoder.scala index 6f27bb6171f..54c05c233ff 100644 --- a/play-json/src/main/scala/org/http4s/play/PlayEntityDecoder.scala +++ b/play-json/src/main/scala/org/http4s/play/PlayEntityDecoder.scala @@ -16,7 +16,7 @@ package org.http4s.play -import cats.effect.Sync +import cats.effect.Concurrent import org.http4s.EntityDecoder import play.api.libs.json.Reads @@ -24,7 +24,7 @@ import play.api.libs.json.Reads * the scope without need to explicitly call `jsonOf`. */ trait PlayEntityDecoder { - implicit def playEntityDecoder[F[_]: Sync, A: Reads]: EntityDecoder[F, A] = jsonOf[F, A] + implicit def playEntityDecoder[F[_]: Concurrent, A: Reads]: EntityDecoder[F, A] = jsonOf[F, A] } object PlayEntityDecoder extends PlayEntityDecoder diff --git a/play-json/src/main/scala/org/http4s/play/PlayInstances.scala b/play-json/src/main/scala/org/http4s/play/PlayInstances.scala index 7c6e14a548d..afd6a2f00aa 100644 --- a/play-json/src/main/scala/org/http4s/play/PlayInstances.scala +++ b/play-json/src/main/scala/org/http4s/play/PlayInstances.scala @@ -16,7 +16,7 @@ package org.http4s.play -import cats.effect.Sync +import cats.effect.Concurrent import fs2.Chunk import org.http4s.headers.`Content-Type` import org.http4s.{ @@ -33,7 +33,7 @@ import org.http4s.play.Parser.facade import play.api.libs.json._ trait PlayInstances { - def jsonOf[F[_]: Sync, A](implicit decoder: Reads[A]): EntityDecoder[F, A] = + def jsonOf[F[_]: Concurrent, A](implicit decoder: Reads[A]): EntityDecoder[F, A] = jsonDecoder[F].flatMapR { json => decoder .reads(json) @@ -44,7 +44,7 @@ trait PlayInstances { ) } - implicit def jsonDecoder[F[_]: Sync]: EntityDecoder[F, JsValue] = + implicit def jsonDecoder[F[_]: Concurrent]: EntityDecoder[F, JsValue] = jawn.jawnDecoder[F, JsValue] def jsonEncoderOf[F[_], A: Writes]: EntityEncoder[F, A] = @@ -54,7 +54,7 @@ trait PlayInstances { EntityEncoder[F, Chunk[Byte]] .contramap[JsValue] { json => val bytes = json.toString.getBytes("UTF8") - Chunk.bytes(bytes) + Chunk.array(bytes) } .withContentType(`Content-Type`(MediaType.application.json)) @@ -71,7 +71,7 @@ trait PlayInstances { ) } - implicit class MessageSyntax[F[_]: Sync](self: Message[F]) { + implicit class MessageSyntax[F[_]: Concurrent](self: Message[F]) { def decodeJson[A: Reads]: F[A] = self.as(implicitly, jsonOf[F, A]) } diff --git a/play-json/src/test/scala/org/http4s/play/PlaySuite.scala b/play-json/src/test/scala/org/http4s/play/PlaySuite.scala index 887e6d86e55..bfb1edcc175 100644 --- a/play-json/src/test/scala/org/http4s/play/PlaySuite.scala +++ b/play-json/src/test/scala/org/http4s/play/PlaySuite.scala @@ -19,7 +19,6 @@ package play.test // Get out of play package so we can import custom instances import _root_.play.api.libs.json._ import cats.effect.IO -import cats.effect.laws.util.TestContext import org.http4s.headers.`Content-Type` import org.http4s.jawn.JawnDecodeSupportSuite import org.http4s.play._ @@ -27,8 +26,6 @@ import org.http4s.syntax.all._ // Originally based on CirceSpec class PlaySuite extends JawnDecodeSupportSuite[JsValue] { - implicit val testContext: TestContext = TestContext() - testJsonDecoder(jsonDecoder) sealed case class Foo(bar: Int) diff --git a/project/Http4sPlugin.scala b/project/Http4sPlugin.scala index 30294cc1de1..9ce6ecfd3c5 100644 --- a/project/Http4sPlugin.scala +++ b/project/Http4sPlugin.scala @@ -76,13 +76,7 @@ object Http4sPlugin extends AutoPlugin { dependencyUpdatesFilter -= moduleFilter(organization = "org.apache.tomcat", revision = "10.0.*"), // Cursed release. Calls ByteBuffer incompatibly with JDK8 dependencyUpdatesFilter -= moduleFilter(name = "boopickle", revision = "1.3.2"), - // CE3 - dependencyUpdatesFilter -= moduleFilter(name = "log4cats-*", revision = "2.*"), - dependencyUpdatesFilter -= moduleFilter(name = "ip4s-*", revision = "3.*"), - dependencyUpdatesFilter -= moduleFilter(name = "cats-effect*", revision = "3.*"), - dependencyUpdatesFilter -= moduleFilter(name = "vault", revision = "3.*"), - dependencyUpdatesFilter -= moduleFilter(name = "keypool", revision = "0.4.*"), - dependencyUpdatesFilter -= moduleFilter(organization = "co.fs2", name = "fs2-*", revision = "3.*"), + // Breaking change deferred to 1.0 dependencyUpdatesFilter -= moduleFilter(organization = "io.prometheus", revision = "0.12.*"), headerSources / excludeFilter := HiddenFileFilter || @@ -131,13 +125,6 @@ object Http4sPlugin extends AutoPlugin { nowarnCompatAnnotationProvider := None, - mimaPreviousArtifacts := { - mimaPreviousArtifacts.value.filterNot( - // cursed release - _.revision == "0.21.10" - ) - }, - doctestTestFramework := DoctestTestFramework.Munit, ) @@ -278,11 +265,14 @@ object Http4sPlugin extends AutoPlugin { githubWorkflowPublishPostamble := Seq( setupHugoStep, sitePublishStep("website"), - sitePublishStep("docs") + // sitePublishStep("docs") ), // this results in nonexistant directories trying to be compressed githubWorkflowArtifactUpload := false, - githubWorkflowAddedJobs := Seq(siteBuildJob("website"), siteBuildJob("docs")), + githubWorkflowAddedJobs := Seq( + siteBuildJob("website"), + siteBuildJob("docs") + ), ) } @@ -295,22 +285,22 @@ object Http4sPlugin extends AutoPlugin { val boopickle = "1.4.0" val caseInsensitive = "1.1.4" val cats = "2.6.1" - val catsEffect = "2.5.4" + val catsEffect = "3.2.9" val catsParse = "0.3.4" val circe = "0.14.1" val cryptobits = "1.3" val disciplineCore = "1.1.5" val dropwizardMetrics = "4.2.3" - val fs2 = "2.5.9" - val ip4s = "2.0.3" + val fs2 = "3.1.2" + val ip4s = "3.0.3" val javaWebSocket = "1.5.2" val jawn = "1.2.0" - val jawnFs2 = "1.1.3" + val jawnFs2 = "2.1.0" val jetty = "9.4.43.v20210629" - val keypool = "0.3.5" + val keypool = "0.4.7" val literally = "1.0.2" val logback = "1.2.5" - val log4cats = "1.3.1" + val log4cats = "2.1.1" val log4s = "1.10.0" val munit = "0.7.29" val munitCatsEffect = "1.0.5" @@ -332,7 +322,7 @@ object Http4sPlugin extends AutoPlugin { val tomcat = "9.0.53" val treehugger = "0.4.4" val twirl = "1.4.2" - val vault = "2.1.13" + val vault = "3.0.4" } lazy val asyncHttpClient = "org.asynchttpclient" % "async-http-client" % V.asyncHttpClient @@ -343,7 +333,9 @@ object Http4sPlugin extends AutoPlugin { lazy val caseInsensitiveTesting = "org.typelevel" %% "case-insensitive-testing" % V.caseInsensitive lazy val catsCore = "org.typelevel" %% "cats-core" % V.cats lazy val catsEffect = "org.typelevel" %% "cats-effect" % V.catsEffect + lazy val catsEffectStd = "org.typelevel" %% "cats-effect-std" % V.catsEffect lazy val catsEffectLaws = "org.typelevel" %% "cats-effect-laws" % V.catsEffect + lazy val catsEffectTestkit = "org.typelevel" %% "cats-effect-testkit" % V.catsEffect lazy val catsLaws = "org.typelevel" %% "cats-laws" % V.cats lazy val catsParse = "org.typelevel" %% "cats-parse" % V.catsParse lazy val circeCore = "io.circe" %% "circe-core" % V.circe @@ -362,8 +354,8 @@ object Http4sPlugin extends AutoPlugin { lazy val ip4sCore = "com.comcast" %% "ip4s-core" % V.ip4s lazy val ip4sTestKit = "com.comcast" %% "ip4s-test-kit" % V.ip4s lazy val javaxServletApi = "javax.servlet" % "javax.servlet-api" % V.servlet + lazy val jawnFs2 = "org.typelevel" %% "jawn-fs2" % V.jawnFs2 lazy val javaWebSocket = "org.java-websocket" % "Java-WebSocket" % V.javaWebSocket - lazy val jawnFs2 = "org.http4s" %% "jawn-fs2" % V.jawnFs2 lazy val jawnParser = "org.typelevel" %% "jawn-parser" % V.jawn lazy val jawnPlay = "org.typelevel" %% "jawn-play" % V.jawn lazy val jettyClient = "org.eclipse.jetty" % "jetty-client" % V.jetty @@ -381,7 +373,7 @@ object Http4sPlugin extends AutoPlugin { lazy val log4s = "org.log4s" %% "log4s" % V.log4s lazy val logbackClassic = "ch.qos.logback" % "logback-classic" % V.logback lazy val munit = "org.scalameta" %% "munit" % V.munit - lazy val munitCatsEffect = "org.typelevel" %% "munit-cats-effect-2" % V.munitCatsEffect + lazy val munitCatsEffect = "org.typelevel" %% "munit-cats-effect-3" % V.munitCatsEffect lazy val munitDiscipline = "org.typelevel" %% "discipline-munit" % V.munitDiscipline lazy val nettyBuffer = "io.netty" % "netty-buffer" % V.netty lazy val nettyCodecHttp = "io.netty" % "netty-codec-http" % V.netty diff --git a/prometheus-metrics/src/main/scala/org/http4s/metrics/prometheus/PrometheusExportService.scala b/prometheus-metrics/src/main/scala/org/http4s/metrics/prometheus/PrometheusExportService.scala index 4dc8280760b..45622c46fd8 100644 --- a/prometheus-metrics/src/main/scala/org/http4s/metrics/prometheus/PrometheusExportService.scala +++ b/prometheus-metrics/src/main/scala/org/http4s/metrics/prometheus/PrometheusExportService.scala @@ -25,7 +25,7 @@ import java.io.StringWriter import org.http4s.Uri.Path import org.http4s._ -import org.http4s.Http4s._ +import org.http4s.syntax.all._ /* * PrometheusExportService Contains an HttpService diff --git a/prometheus-metrics/src/test/scala/org/http4s/metrics/prometheus/util.scala b/prometheus-metrics/src/test/scala/org/http4s/metrics/prometheus/util.scala index f20e0462c9c..fe03725ebae 100644 --- a/prometheus-metrics/src/test/scala/org/http4s/metrics/prometheus/util.scala +++ b/prometheus-metrics/src/test/scala/org/http4s/metrics/prometheus/util.scala @@ -24,7 +24,7 @@ import java.util.concurrent.{TimeUnit, TimeoutException} import org.http4s.{Request, Response} import org.http4s.dsl.io._ import org.http4s.Method.GET -import scala.concurrent.duration.TimeUnit +import scala.concurrent.duration.FiniteDuration object util { def stub: PartialFunction[Request[IO], IO[Response[IO]]] = { @@ -135,16 +135,18 @@ object util { new Clock[F] { private var count = 0L - override def realTime(unit: TimeUnit): F[Long] = + override def applicative: cats.Applicative[F] = Sync[F] + + override def realTime: F[FiniteDuration] = Sync[F].delay { count += 50 - unit.convert(count, TimeUnit.MILLISECONDS) + FiniteDuration(count, TimeUnit.MILLISECONDS) } - override def monotonic(unit: TimeUnit): F[Long] = + override def monotonic: F[FiniteDuration] = Sync[F].delay { count += 50 - unit.convert(count, TimeUnit.MILLISECONDS) + FiniteDuration(count, TimeUnit.MILLISECONDS) } } } diff --git a/scala-xml/src/main/scala/scalaxml/ElemInstances.scala b/scala-xml/src/main/scala/scalaxml/ElemInstances.scala index cceb4fa6008..ee43e77523a 100644 --- a/scala-xml/src/main/scala/scalaxml/ElemInstances.scala +++ b/scala-xml/src/main/scala/scalaxml/ElemInstances.scala @@ -17,12 +17,10 @@ package org.http4s package scalaxml -import cats.effect.Sync -import cats.syntax.all._ +import cats.effect.Concurrent import java.io.{ByteArrayInputStream, StringWriter} import javax.xml.parsers.SAXParserFactory -import cats.data.EitherT import org.http4s.headers.`Content-Type` import scala.util.control.NonFatal @@ -48,7 +46,7 @@ trait ElemInstances { * * @return an XML element */ - implicit def xml[F[_]](implicit F: Sync[F]): EntityDecoder[F, Elem] = { + implicit def xml[F[_]](implicit F: Concurrent[F]): EntityDecoder[F, Elem] = { import EntityDecoder._ decodeBy(MediaType.text.xml, MediaType.text.html, MediaType.application.xml) { msg => val source = new InputSource() @@ -57,9 +55,8 @@ trait ElemInstances { collectBinary(msg).flatMap[DecodeFailure, Elem] { chunk => source.setByteStream(new ByteArrayInputStream(chunk.toArray)) val saxParser = saxFactory.newSAXParser() - EitherT( - F.delay(XML.loadXML(source, saxParser)).attempt - ).leftFlatMap { + try DecodeResult.successT[F, Elem](XML.loadXML(source, saxParser)) + catch { case e: SAXParseException => DecodeResult.failureT(MalformedMessageBodyFailure("Invalid XML", Some(e))) case NonFatal(e) => DecodeResult(F.raiseError[Either[DecodeFailure, Elem]](e)) diff --git a/scala-xml/src/test/scala/scalaxml/ScalaXmlSuite.scala b/scala-xml/src/test/scala/scalaxml/ScalaXmlSuite.scala index f0a80dc3d8f..64b9081a88b 100644 --- a/scala-xml/src/test/scala/scalaxml/ScalaXmlSuite.scala +++ b/scala-xml/src/test/scala/scalaxml/ScalaXmlSuite.scala @@ -20,7 +20,7 @@ package scalaxml import cats.effect._ import cats.syntax.all._ import fs2.Stream -import fs2.text.{utf8Decode, utf8Encode} +import fs2.text.utf8 import org.http4s.headers.`Content-Type` import org.http4s.laws.discipline.arbitrary._ import org.http4s.Status.Ok @@ -32,9 +32,9 @@ import java.nio.charset.StandardCharsets class ScalaXmlSuite extends Http4sSuite { def getBody(body: EntityBody[IO]): IO[String] = - body.through(utf8Decode).foldMonoid.compile.lastOrError + body.through(utf8.decode).foldMonoid.compile.lastOrError - def strBody(body: String): EntityBody[IO] = Stream(body).through(utf8Encode) + def strBody(body: String): EntityBody[IO] = Stream(body).through(utf8.encode) val server: Request[IO] => IO[Response[IO]] = { req => req.decode { (elem: Elem) => @@ -80,7 +80,7 @@ class ScalaXmlSuite extends Http4sSuite { xmlEncoder[IO](Charset.`UTF-8`) .toEntity(hello) .body - .through(fs2.text.utf8Decode) + .through(fs2.text.utf8.decode) .compile .string, """ @@ -132,7 +132,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse UTF-8 charset with explicit encoding") { // https://tools.ietf.org/html/rfc7303#section-8.1 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes( StandardCharsets.UTF_8)), "application/xml; charset=utf-8", @@ -143,7 +143,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse UTF-8 charset with no encoding") { // https://tools.ietf.org/html/rfc7303#section-8.1 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes(StandardCharsets.UTF_8)), "application/xml; charset=utf-8", "Günther") @@ -152,7 +152,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse UTF-16 charset with explicit encoding") { // https://tools.ietf.org/html/rfc7303#section-8.2 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes( StandardCharsets.UTF_16)), "application/xml; charset=utf-16", @@ -163,7 +163,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse UTF-16 charset with no encoding") { // https://tools.ietf.org/html/rfc7303#section-8.2 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes(StandardCharsets.UTF_16)), "application/xml; charset=utf-16", "Günther") @@ -172,7 +172,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse omitted charset and 8-Bit MIME Entity") { // https://tools.ietf.org/html/rfc7303#section-8.3 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes( StandardCharsets.ISO_8859_1)), "application/xml", @@ -183,7 +183,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse omitted charset and 16-Bit MIME Entity") { // https://tools.ietf.org/html/rfc7303#section-8.4 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes( StandardCharsets.UTF_16)), "application/xml", @@ -193,7 +193,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse omitted charset, no internal encoding declaration") { // https://tools.ietf.org/html/rfc7303#section-8.5 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes(StandardCharsets.UTF_8)), "application/xml", "Günther") @@ -202,7 +202,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse utf-16be charset") { // https://tools.ietf.org/html/rfc7303#section-8.6 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes(StandardCharsets.UTF_16BE)), "application/xml; charset=utf-16be", "Günther") @@ -211,7 +211,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse non-utf charset") { // https://tools.ietf.org/html/rfc7303#section-8.7 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes( "iso-2022-kr")), "application/xml; charset=iso-2022kr", @@ -222,7 +222,7 @@ class ScalaXmlSuite extends Http4sSuite { test("parse conflicting charset and internal encoding") { // https://tools.ietf.org/html/rfc7303#section-8.8 encodingTest( - Chunk.bytes( + Chunk.array( """""".getBytes( StandardCharsets.ISO_8859_1)), "application/xml; charset=iso-8859-1", diff --git a/scalafix/project/build.properties b/scalafix/project/build.properties index 0b2e09c5ac9..dbae93bcfd5 100644 --- a/scalafix/project/build.properties +++ b/scalafix/project/build.properties @@ -1 +1 @@ -sbt.version=1.4.7 +sbt.version=1.4.9 diff --git a/server/src/main/scala/org/http4s/server/ServerBuilder.scala b/server/src/main/scala/org/http4s/server/ServerBuilder.scala index 3d9b6a5a91f..7e9113acbd9 100644 --- a/server/src/main/scala/org/http4s/server/ServerBuilder.scala +++ b/server/src/main/scala/org/http4s/server/ServerBuilder.scala @@ -19,10 +19,9 @@ package server import cats.syntax.all._ import cats.effect._ -import cats.effect.concurrent.Ref import fs2._ import fs2.concurrent.{Signal, SignallingRef} -import java.net.{InetAddress, InetSocketAddress} +import java.net.InetSocketAddress import org.http4s.internal.BackendBuilder import scala.collection.immutable @@ -58,7 +57,7 @@ trait ServerBuilder[F[_]] extends BackendBuilder[F, Server] { final def serve: Stream[F, ExitCode] = for { signal <- Stream.eval(SignallingRef[F, Boolean](false)) - exitCode <- Stream.eval(Ref[F].of(ExitCode.Success)) + exitCode <- Stream.eval(F.ref(ExitCode.Success)) serve <- serveWhile(signal, exitCode) } yield serve @@ -79,29 +78,6 @@ trait ServerBuilder[F[_]] extends BackendBuilder[F, Server] { final def withoutBanner: Self = withBanner(immutable.Seq.empty) } -object ServerBuilder { - @deprecated("Use InetAddress.getLoopbackAddress.getHostAddress", "0.20.0-M2") - val LoopbackAddress = InetAddress.getLoopbackAddress.getHostAddress - @deprecated("Use org.http4s.server.defaults.Host", "0.20.0-M2") - val DefaultHost = defaults.Host - @deprecated("Use org.http4s.server.defaults.HttpPort", "0.20.0-M2") - val DefaultHttpPort = defaults.HttpPort - @deprecated("Use org.http4s.server.defaults.SocketAddress", "0.20.0-M2") - val DefaultSocketAddress = defaults.SocketAddress - @deprecated("Use org.http4s.server.defaults.Banner", "0.20.0-M2") - val DefaultBanner = defaults.Banner -} - -object IdleTimeoutSupport { - @deprecated("Moved to org.http4s.server.defaults.IdleTimeout", "0.20.0-M2") - val DefaultIdleTimeout = defaults.IdleTimeout -} - -object AsyncTimeoutSupport { - @deprecated("Moved to org.http4s.server.defaults.AsyncTimeout", "0.20.0-M2") - val DefaultAsyncTimeout = defaults.ResponseTimeout -} - object SSLKeyStoreSupport { final case class StoreInfo(path: String, password: String) } diff --git a/server/src/main/scala/org/http4s/server/middleware/BracketRequestResponse.scala b/server/src/main/scala/org/http4s/server/middleware/BracketRequestResponse.scala index bea6a1d9c03..45f334418a9 100644 --- a/server/src/main/scala/org/http4s/server/middleware/BracketRequestResponse.scala +++ b/server/src/main/scala/org/http4s/server/middleware/BracketRequestResponse.scala @@ -22,6 +22,8 @@ import cats.effect.syntax.all._ import cats.implicits._ import org.http4s._ import org.http4s.server._ +import cats.effect.kernel.Resource.ExitCase +import cats.Applicative /** Middelwares which allow for bracketing on a Request/Response, including * the completion of the Response body stream. @@ -110,25 +112,29 @@ object BracketRequestResponse { */ def bracketRequestResponseCaseRoutes_[F[_], A, B]( acquire: Request[F] => F[ContextRequest[F, A]] - )(release: (A, Option[B], ExitCase[Throwable]) => F[Unit])(implicit - F: BracketThrow[F]): FullContextMiddleware[F, A, B] = + )(release: (A, Option[B], Outcome[F, Throwable, Unit]) => F[Unit])( + implicit // TODO: Maybe we can merge A and Outcome + F: MonadCancelThrow[F]): FullContextMiddleware[F, A, B] = + // format: off (bracketRoutes: Kleisli[OptionT[F, *], ContextRequest[F, A], ContextResponse[F, B]]) => Kleisli((request: Request[F]) => OptionT( acquire(request).flatMap(contextRequest => bracketRoutes(contextRequest) - .foldF(release(contextRequest.context, None, ExitCase.Completed) *> F.pure( + .foldF(release(contextRequest.context, None, Outcome.succeeded(F.unit)) *> F.pure( None: Option[Response[F]]))(contextResponse => F.pure(Some(contextResponse.response.copy(body = contextResponse.response.body.onFinalizeCaseWeak(ec => - release(contextRequest.context, Some(contextResponse.context), ec)))))) + release(contextRequest.context, Some(contextResponse.context), exitCaseToOutcome(ec))))))) .guaranteeCase { - case ExitCase.Completed => - F.unit - case otherwise => - release(contextRequest.context, None, otherwise) + case Outcome.Succeeded(_) => + F.unit + case otherwise => + release(contextRequest.context, None, otherwise.void) + }) )) + // format: on /** Bracket on the start of a request and the completion of processing the * response ''body Stream''. @@ -151,11 +157,11 @@ object BracketRequestResponse { */ def bracketRequestResponseCaseRoutes[F[_], A]( acquire: F[A] - )(release: (A, ExitCase[Throwable]) => F[Unit])(implicit - F: BracketThrow[F]): ContextMiddleware[F, A] = + )(release: (A, Outcome[F, Throwable, Unit]) => F[Unit])(implicit + F: MonadCancelThrow[F]): ContextMiddleware[F, A] = contextRoutes => bracketRequestResponseCaseRoutes_[F, A, Unit](req => - acquire.map(a => ContextRequest(a, req))) { case (a, _, ec) => release(a, ec) }(F)( + acquire.map(a => ContextRequest(a, req))) { case (a, _, oc) => release(a, oc) }(F)( contextRoutes.map(resp => ContextResponse[F, Unit]((), resp))) /** As [[#bracketRequestResponseCaseRoutes]] but defined for [[HttpApp]], @@ -165,7 +171,7 @@ object BracketRequestResponse { */ def bracketRequestResponseCaseApp[F[_], A]( acquire: F[A] - )(release: (A, ExitCase[Throwable]) => F[Unit])(implicit F: BracketThrow[F]) + )(release: (A, Outcome[F, Throwable, Unit]) => F[Unit])(implicit F: MonadCancelThrow[F]) : Kleisli[F, ContextRequest[F, A], Response[F]] => Kleisli[F, Request[F], Response[F]] = (contextService: Kleisli[F, ContextRequest[F, A], Response[F]]) => Kleisli((request: Request[F]) => @@ -173,12 +179,14 @@ object BracketRequestResponse { contextService .run(ContextRequest(a, request)) .map(response => - response.copy(body = response.body.onFinalizeCaseWeak(ec => release(a, ec)))) + response.copy(body = + response.body.onFinalizeCaseWeak(ec => release(a, exitCaseToOutcome(ec))))) .guaranteeCase { - case ExitCase.Completed => + case Outcome.Succeeded(_) => F.unit case otherwise => - release(a, otherwise) + release(a, otherwise.void) + })) /** As [[#bracketRequestResponseCaseRoutes]], but `release` is simplified, ignoring @@ -187,7 +195,7 @@ object BracketRequestResponse { * @note $releaseWarning */ def bracketRequestResponseRoutes[F[_], A](acquire: F[A])(release: A => F[Unit])(implicit - F: BracketThrow[F]): ContextMiddleware[F, A] = + F: MonadCancelThrow[F]): ContextMiddleware[F, A] = bracketRequestResponseCaseRoutes[F, A](acquire) { case (a, _) => release(a) } @@ -198,7 +206,7 @@ object BracketRequestResponse { * @note $releaseWarning */ def bracketRequestResponseApp[F[_], A](acquire: F[A])(release: A => F[Unit])(implicit - F: BracketThrow[F]) + F: MonadCancelThrow[F]) : Kleisli[F, ContextRequest[F, A], Response[F]] => Kleisli[F, Request[F], Response[F]] = bracketRequestResponseCaseApp[F, A](acquire) { case (a, _) => release(a) @@ -211,7 +219,7 @@ object BracketRequestResponse { */ def bracketRequestResponseRoutesR[F[_], A]( resource: Resource[F, A] - )(implicit F: BracketThrow[F]): ContextMiddleware[F, A] = { + )(implicit F: MonadCancelThrow[F]): ContextMiddleware[F, A] = { (contextRoutes: ContextRoutes[A, F]) => val contextRoutes0: ContextRoutes[(A, F[Unit]), F] = contextRoutes.local(_.map(_._1)) @@ -227,7 +235,7 @@ object BracketRequestResponse { */ def bracketRequestResponseAppR[F[_], A]( resource: Resource[F, A] - )(implicit F: BracketThrow[F]) + )(implicit F: MonadCancelThrow[F]) : Kleisli[F, ContextRequest[F, A], Response[F]] => Kleisli[F, Request[F], Response[F]] = { (contextApp: Kleisli[F, ContextRequest[F, A], Response[F]]) => val contextApp0: Kleisli[F, ContextRequest[F, (A, F[Unit])], Response[F]] = @@ -236,4 +244,13 @@ object BracketRequestResponse { resource.allocated )(_._2)(F)(contextApp0) } + + // TODO (ce3-ra): replace with ExitCase#toOutcome after CE3-M5 + def exitCaseToOutcome[F[_]](ec: ExitCase)(implicit + F: Applicative[F]): Outcome[F, Throwable, Unit] = + ec match { + case ExitCase.Succeeded => Outcome.succeeded(F.unit) + case ExitCase.Errored(e) => Outcome.errored(e) + case ExitCase.Canceled => Outcome.canceled + } } diff --git a/server/src/main/scala/org/http4s/server/middleware/CORS.scala b/server/src/main/scala/org/http4s/server/middleware/CORS.scala index 1067c15a467..ac362b7d636 100644 --- a/server/src/main/scala/org/http4s/server/middleware/CORS.scala +++ b/server/src/main/scala/org/http4s/server/middleware/CORS.scala @@ -314,7 +314,7 @@ sealed class CORSPolicy( @deprecated("Does not return 200 on preflight requests. Use the Applicative version", "0.21.28") protected[CORSPolicy] def apply[F[_]: Functor, G[_]](http: Http[F, G]): Http[F, G] = { logger.warn( - "This CORSPolicy does not return 200 on preflight requests. It's kept for binary compatibility, but it's buggy. If you see this, upgrade to v0.22.4 or greater.") + "This CORSPolicy does not return 200 on preflight requests. It's kept for binary compatibility, but it's buggy. If you see this, upgrade to v0.23.3 or greater.") impl(http, http) } diff --git a/server/src/main/scala/org/http4s/server/middleware/CSRF.scala b/server/src/main/scala/org/http4s/server/middleware/CSRF.scala index 1735df21ce8..77c321e7cbb 100644 --- a/server/src/main/scala/org/http4s/server/middleware/CSRF.scala +++ b/server/src/main/scala/org/http4s/server/middleware/CSRF.scala @@ -21,7 +21,7 @@ package middleware import cats.~> import cats.Applicative import cats.data.{EitherT, Kleisli} -import cats.effect.Sync +import cats.effect.{Concurrent, Sync} import cats.syntax.all._ import java.nio.charset.StandardCharsets import java.security.{MessageDigest, SecureRandom} @@ -281,7 +281,7 @@ object CSRF { headerCheck = defaultOriginCheck(_, host, scheme, port) ) - def withDefaultOriginCheckFormAware[F[_]: Sync, G[_]: Sync](fieldName: String, nt: G ~> F)( + def withDefaultOriginCheckFormAware[F[_]: Sync, G[_]: Concurrent](fieldName: String, nt: G ~> F)( key: SecretKey, host: String, scheme: Scheme, @@ -390,7 +390,7 @@ object CSRF { csrf => (r, http) => csrf.getHeaderToken(r).fold(csrf.onfailureF)(csrf.checkCSRFToken(r, http, _)) - def checkCSRFinHeaderAndForm[F[_], G[_]: Sync](fieldName: String, nt: G ~> F)(implicit + def checkCSRFinHeaderAndForm[F[_], G[_]: Concurrent](fieldName: String, nt: G ~> F)(implicit F: Sync[F] ): CSRF[F, G] => CSRFCheck[F, G] = { csrf => (r, http) => def getFormToken: F[Option[String]] = { diff --git a/server/src/main/scala/org/http4s/server/middleware/Caching.scala b/server/src/main/scala/org/http4s/server/middleware/Caching.scala index 4599c392719..3536b3481ee 100644 --- a/server/src/main/scala/org/http4s/server/middleware/Caching.scala +++ b/server/src/main/scala/org/http4s/server/middleware/Caching.scala @@ -16,9 +16,8 @@ package org.http4s.server.middleware -import cats._ import cats.syntax.all._ -import cats.effect.{MonadThrow => _, _} +import cats.effect._ import cats.data._ import org.http4s._ import org.http4s.headers.{Date => HDate, _} @@ -35,7 +34,7 @@ object Caching { /** Middleware that implies responses should NOT be cached. * This is a best attempt, many implementors of caching have done so differently. */ - def `no-store`[G[_]: Monad: Clock, F[_], A]( + def `no-store`[G[_]: Temporal, F[_], A]( http: Kleisli[G, A, Response[F]]): Kleisli[G, A, Response[F]] = Kleisli { (a: A) => for { @@ -48,7 +47,7 @@ object Caching { */ def `no-store-response`[G[_]]: PartiallyAppliedNoStoreCache[G] = new PartiallyAppliedNoStoreCache[G] { - def apply[F[_]](resp: Response[F])(implicit M: Monad[G], C: Clock[G]): G[Response[F]] = + def apply[F[_]](resp: Response[F])(implicit G: Temporal[G]): G[Response[F]] = HttpDate.current[G].map(now => resp.putHeaders(HDate(now)).putHeaders(noStoreStaticHeaders)) } @@ -89,7 +88,7 @@ object Caching { * Note: If set to Duration.Inf, lifetime falls back to * 10 years for support of Http1 caches. */ - def publicCache[G[_]: MonadThrow: Clock, F[_]](lifetime: Duration, http: Http[G, F]): Http[G, F] = + def publicCache[G[_]: Temporal, F[_]](lifetime: Duration, http: Http[G, F]): Http[G, F] = cache( lifetime, Either.left(CacheDirective.public), @@ -110,7 +109,7 @@ object Caching { * Note: If set to Duration.Inf, lifetime falls back to * 10 years for support of Http1 caches. */ - def privateCache[G[_]: MonadThrow: Clock, F[_]]( + def privateCache[G[_]: Temporal, F[_]]( lifetime: Duration, http: Http[G, F], fieldNames: List[CIString] = Nil): Http[G, F] = @@ -139,7 +138,7 @@ object Caching { * Note: If set to Duration.Inf, lifetime falls back to * 10 years for support of Http1 caches. */ - def cache[G[_]: MonadThrow: Clock, F[_]]( + def cache[G[_]: Temporal, F[_]]( lifetime: Duration, isPublic: Either[CacheDirective.public.type, CacheDirective.`private`], methodToSetOn: Method => Boolean, @@ -177,8 +176,7 @@ object Caching { // to explicitly set an Expire which requires some time interval to work } new PartiallyAppliedCache[G] { - override def apply[F[_]]( - resp: Response[F])(implicit M: MonadThrow[G], C: Clock[G]): G[Response[F]] = + override def apply[F[_]](resp: Response[F])(implicit G: Temporal[G]): G[Response[F]] = for { now <- HttpDate.current[G] expires <- @@ -199,10 +197,10 @@ object Caching { } trait PartiallyAppliedCache[G[_]] { - def apply[F[_]](resp: Response[F])(implicit M: MonadThrow[G], C: Clock[G]): G[Response[F]] + def apply[F[_]](resp: Response[F])(implicit G: Temporal[G]): G[Response[F]] } trait PartiallyAppliedNoStoreCache[G[_]] { - def apply[F[_]](resp: Response[F])(implicit M: Monad[G], C: Clock[G]): G[Response[F]] + def apply[F[_]](resp: Response[F])(implicit G: Temporal[G]): G[Response[F]] } } diff --git a/server/src/main/scala/org/http4s/server/middleware/ChunkAggregator.scala b/server/src/main/scala/org/http4s/server/middleware/ChunkAggregator.scala index eace2ceb815..f37fd6f290a 100644 --- a/server/src/main/scala/org/http4s/server/middleware/ChunkAggregator.scala +++ b/server/src/main/scala/org/http4s/server/middleware/ChunkAggregator.scala @@ -35,7 +35,7 @@ object ChunkAggregator { f( response.body.chunks.compile.toVector .map { vec => - val body = Chunk.concatBytes(vec) + val body = Chunk.concat(vec) response .withBodyStream(Stream.chunk(body)) .transformHeaders(removeChunkedTransferEncoding(body.size.toLong)) diff --git a/server/src/main/scala/org/http4s/server/middleware/ConcurrentRequests.scala b/server/src/main/scala/org/http4s/server/middleware/ConcurrentRequests.scala index ef2eac44d00..c58688ee6ed 100644 --- a/server/src/main/scala/org/http4s/server/middleware/ConcurrentRequests.scala +++ b/server/src/main/scala/org/http4s/server/middleware/ConcurrentRequests.scala @@ -18,7 +18,6 @@ package org.http4s.server.middleware import cats.data._ import cats.effect._ -import cats.effect.concurrent._ import cats.implicits._ import org.http4s._ import org.http4s.server._ @@ -46,10 +45,8 @@ object ConcurrentRequests { * @note This is the same as [[#route]], but allows for the inner and outer * effect types to differ. */ - def route2[F[_]: Sync, G[_]: Sync]( - onIncrement: Long => G[Unit], - onDecrement: Long => G[Unit] - ): F[ContextMiddleware[G, Long]] = + def route2[F[_]: Sync, G[_]: Async]( // TODO (ce3-ra): Sync + MonadCancel + onIncrement: Long => G[Unit], onDecrement: Long => G[Unit]): F[ContextMiddleware[G, Long]] = Ref .in[F, G, Long](0L) .map(ref => @@ -66,14 +63,14 @@ object ConcurrentRequests { * @note `onIncrement` should never be < 1 and `onDecrement` should never * be value < 0. */ - def route[F[_]: Sync]( + def route[F[_]: Async]( onIncrement: Long => F[Unit], onDecrement: Long => F[Unit] ): F[ContextMiddleware[F, Long]] = route2[F, F](onIncrement, onDecrement) /** As [[#route]], but runs the same effect on increment and decrement of the concurrent request count. */ - def onChangeRoute[F[_]: Sync](onChange: Long => F[Unit]): F[ContextMiddleware[F, Long]] = + def onChangeRoute[F[_]: Async](onChange: Long => F[Unit]): F[ContextMiddleware[F, Long]] = route[F](onChange, onChange) /** Run a side effect each time the concurrent request count increments and @@ -88,7 +85,7 @@ object ConcurrentRequests { * @note This is the same as [[#app]], but allows for the inner and outer * effect types to differ. */ - def app2[F[_]: Sync, G[_]: Sync]( + def app2[F[_]: Sync, G[_]: Async]( onIncrement: Long => G[Unit], onDecrement: Long => G[Unit] ): F[Kleisli[G, ContextRequest[G, Long], Response[G]] => Kleisli[G, Request[G], Response[G]]] = @@ -108,14 +105,14 @@ object ConcurrentRequests { * @note `onIncrement` should never be < 1 and `onDecrement` should never * be value < 0. */ - def app[F[_]: Sync]( + def app[F[_]: Async]( onIncrement: Long => F[Unit], onDecrement: Long => F[Unit] ): F[Kleisli[F, ContextRequest[F, Long], Response[F]] => Kleisli[F, Request[F], Response[F]]] = app2[F, F](onIncrement, onDecrement) /** As [[#app]], but runs the same effect on increment and decrement of the concurrent request count. */ - def onChangeApp[F[_]: Sync](onChange: Long => F[Unit]) + def onChangeApp[F[_]: Async](onChange: Long => F[Unit]) : F[Kleisli[F, ContextRequest[F, Long], Response[F]] => Kleisli[F, Request[F], Response[F]]] = app[F](onChange, onChange) } diff --git a/server/src/main/scala/org/http4s/server/middleware/Date.scala b/server/src/main/scala/org/http4s/server/middleware/Date.scala index fd1ccb47e7c..346cba061b4 100644 --- a/server/src/main/scala/org/http4s/server/middleware/Date.scala +++ b/server/src/main/scala/org/http4s/server/middleware/Date.scala @@ -16,7 +16,6 @@ package org.http4s.server.middleware -import cats._ import cats.data.Kleisli import cats.syntax.all._ import cats.effect._ @@ -28,8 +27,7 @@ import org.http4s.headers.{Date => HDate} * by the service. */ object Date { - def apply[G[_]: Monad: Clock, F[_], A]( - k: Kleisli[G, A, Response[F]]): Kleisli[G, A, Response[F]] = + def apply[G[_]: Temporal, F[_], A](k: Kleisli[G, A, Response[F]]): Kleisli[G, A, Response[F]] = Kleisli { a => for { resp <- k(a) @@ -42,9 +40,9 @@ object Date { } yield resp.putHeaders(header) } - def httpRoutes[F[_]: Monad: Clock](routes: HttpRoutes[F]): HttpRoutes[F] = + def httpRoutes[F[_]: Temporal](routes: HttpRoutes[F]): HttpRoutes[F] = apply(routes) - def httpApp[F[_]: Monad: Clock](app: HttpApp[F]): HttpApp[F] = + def httpApp[F[_]: Temporal](app: HttpApp[F]): HttpApp[F] = apply(app) } diff --git a/server/src/main/scala/org/http4s/server/middleware/GZip.scala b/server/src/main/scala/org/http4s/server/middleware/GZip.scala index bc2889ed707..6666e3d8c1e 100644 --- a/server/src/main/scala/org/http4s/server/middleware/GZip.scala +++ b/server/src/main/scala/org/http4s/server/middleware/GZip.scala @@ -24,7 +24,7 @@ import cats.effect.Sync import cats.syntax.all._ import fs2.{Chunk, Pipe, Pull, Stream} import fs2.Stream.chunk -import fs2.compression.deflate +import fs2.compression._ import java.nio.{ByteBuffer, ByteOrder} import java.util.zip.{CRC32, Deflater} import org.http4s.headers._ @@ -38,7 +38,7 @@ object GZip { def apply[F[_]: Functor, G[_]: Sync]( http: Http[F, G], bufferSize: Int = 32 * 1024, - level: Int = Deflater.DEFAULT_COMPRESSION, + level: DeflateParams.Level = DeflateParams.Level.DEFAULT, isZippable: Response[G] => Boolean = defaultIsZippable[G](_: Response[G]) ): Http[F, G] = Kleisli { (req: Request[G]) => @@ -64,7 +64,7 @@ object GZip { private def zipOrPass[F[_]: Sync]( response: Response[F], bufferSize: Int, - level: Int, + level: DeflateParams.Level, isZippable: Response[F] => Boolean): Response[F] = response match { case resp if isZippable(resp) => zipResponse(bufferSize, level, resp) @@ -73,7 +73,7 @@ object GZip { private def zipResponse[F[_]: Sync]( bufferSize: Int, - level: Int, + level: DeflateParams.Level, resp: Response[F]): Response[F] = { logger.trace("GZip middleware encoding content") // Need to add the Gzip header and trailer @@ -82,11 +82,11 @@ object GZip { resp.body .through(trailer(trailerGen, bufferSize)) .through( - deflate( - level = level, - nowrap = true, - bufferSize = bufferSize - )) ++ + Compression[F].deflate( + DeflateParams( + bufferSize = bufferSize, + header = ZLibParams.Header.GZIP, + level = level))) ++ chunk(trailerFinish(trailerGen)) resp .removeHeader[`Content-Length`] @@ -97,7 +97,7 @@ object GZip { private val GZIP_MAGIC_NUMBER = 0x8b1f private val GZIP_LENGTH_MOD = Math.pow(2, 32).toLong - private val header: Chunk[Byte] = Chunk.bytes( + private val header: Chunk[Byte] = Chunk.array( Array( GZIP_MAGIC_NUMBER.toByte, // Magic number (int16) (GZIP_MAGIC_NUMBER >> 8).toByte, // Magic number c @@ -129,7 +129,7 @@ object GZip { } private def trailerFinish(gen: TrailerGen): Chunk[Byte] = - Chunk.bytes( + Chunk.array( ByteBuffer .allocate(Integer.BYTES * 2) .order(ByteOrder.LITTLE_ENDIAN) diff --git a/server/src/main/scala/org/http4s/server/middleware/HttpMethodOverrider.scala b/server/src/main/scala/org/http4s/server/middleware/HttpMethodOverrider.scala index f6028a84382..ffb70a98730 100644 --- a/server/src/main/scala/org/http4s/server/middleware/HttpMethodOverrider.scala +++ b/server/src/main/scala/org/http4s/server/middleware/HttpMethodOverrider.scala @@ -73,7 +73,7 @@ object HttpMethodOverrider { HeaderOverrideStrategy(ci"X-HTTP-Method-Override"), Set(Method.POST)) - val overriddenMethodAttrKey: Key[Method] = Key.newKey[IO, Method].unsafeRunSync() + val overriddenMethodAttrKey: Key[Method] = Key.newKey[SyncIO, Method].unsafeRunSync() /** Simple middleware for HTTP Method Override. * @@ -87,7 +87,7 @@ object HttpMethodOverrider { */ def apply[F[_], G[_]](http: Http[F, G], config: HttpMethodOverriderConfig[F, G])(implicit F: Monad[F], - S: Sync[G]): Http[F, G] = { + S: Concurrent[G]): Http[F, G] = { val parseMethod = (m: String) => Method.fromString(m.toUpperCase) val processRequestWithOriginalMethod = (req: Request[G]) => http(req) diff --git a/server/src/main/scala/org/http4s/server/middleware/Jsonp.scala b/server/src/main/scala/org/http4s/server/middleware/Jsonp.scala index 17c8e11b31e..b3e8f543963 100644 --- a/server/src/main/scala/org/http4s/server/middleware/Jsonp.scala +++ b/server/src/main/scala/org/http4s/server/middleware/Jsonp.scala @@ -76,8 +76,8 @@ object Jsonp { } private def beginJsonp(callback: String) = - Chunk.bytes((callback + "(").getBytes(StandardCharsets.UTF_8)) + Chunk.array((callback + "(").getBytes(StandardCharsets.UTF_8)) private val EndJsonp = - Chunk.bytes(");".getBytes(StandardCharsets.UTF_8)) + Chunk.array(");".getBytes(StandardCharsets.UTF_8)) } diff --git a/server/src/main/scala/org/http4s/server/middleware/Logger.scala b/server/src/main/scala/org/http4s/server/middleware/Logger.scala index 992e763da9d..f17d728f2af 100644 --- a/server/src/main/scala/org/http4s/server/middleware/Logger.scala +++ b/server/src/main/scala/org/http4s/server/middleware/Logger.scala @@ -22,8 +22,7 @@ import cats.~> import cats.arrow.FunctionK import cats.syntax.all._ import cats.data.OptionT -import cats.effect.{BracketThrow, Concurrent, Sync} -import cats.effect.Sync._ +import cats.effect.kernel.{Async, MonadCancelThrow} import fs2.Stream import org.log4s.getLogger import org.typelevel.ci.CIString @@ -39,9 +38,9 @@ object Logger { fk: F ~> G, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, logAction: Option[String => F[Unit]] = None - )(http: Http[G, F])(implicit G: BracketThrow[G], F: Concurrent[F]): Http[G, F] = { + )(http: Http[G, F])(implicit G: MonadCancelThrow[G], F: Async[F]): Http[G, F] = { val log: String => F[Unit] = logAction.getOrElse { s => - Sync[F].delay(logger.info(s)) + F.delay(logger.info(s)) } ResponseLogger(logHeaders, logBody, fk, redactHeadersWhen, log.pure[Option])( RequestLogger(logHeaders, logBody, fk, redactHeadersWhen, log.pure[Option])(http) @@ -54,16 +53,16 @@ object Logger { fk: F ~> G, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, logAction: Option[String => F[Unit]] = None - )(http: Http[G, F])(implicit G: BracketThrow[G], F: Concurrent[F]): Http[G, F] = { + )(http: Http[G, F])(implicit G: MonadCancelThrow[G], F: Async[F]): Http[G, F] = { val log: String => F[Unit] = logAction.getOrElse { s => - Sync[F].delay(logger.info(s)) + F.delay(logger.info(s)) } ResponseLogger.impl(logHeaders, Right(logBody), fk, redactHeadersWhen, log.pure[Option])( RequestLogger.impl(logHeaders, Right(logBody), fk, redactHeadersWhen, log.pure[Option])(http) ) } - def httpApp[F[_]: Concurrent]( + def httpApp[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -71,7 +70,7 @@ object Logger { )(httpApp: HttpApp[F]): HttpApp[F] = apply(logHeaders, logBody, FunctionK.id[F], redactHeadersWhen, logAction)(httpApp) - def httpAppLogBodyText[F[_]: Concurrent]( + def httpAppLogBodyText[F[_]: Async]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -79,7 +78,7 @@ object Logger { )(httpApp: HttpApp[F]): HttpApp[F] = logBodyText(logHeaders, logBody, FunctionK.id[F], redactHeadersWhen, logAction)(httpApp) - def httpRoutes[F[_]: Concurrent]( + def httpRoutes[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -87,7 +86,7 @@ object Logger { )(httpRoutes: HttpRoutes[F]): HttpRoutes[F] = apply(logHeaders, logBody, OptionT.liftK[F], redactHeadersWhen, logAction)(httpRoutes) - def httpRoutesLogBodyText[F[_]: Concurrent]( + def httpRoutesLogBodyText[F[_]: Async]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -99,7 +98,7 @@ object Logger { logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains)( - log: String => F[Unit])(implicit F: Sync[F]): F[Unit] = + log: String => F[Unit])(implicit F: Async[F]): F[Unit] = org.http4s.internal.Logger .logMessage[F, A](message)(logHeaders, logBody, redactHeadersWhen)(log) } diff --git a/server/src/main/scala/org/http4s/server/middleware/MaxActiveRequests.scala b/server/src/main/scala/org/http4s/server/middleware/MaxActiveRequests.scala index dbd33024a98..93db8640eb5 100644 --- a/server/src/main/scala/org/http4s/server/middleware/MaxActiveRequests.scala +++ b/server/src/main/scala/org/http4s/server/middleware/MaxActiveRequests.scala @@ -24,8 +24,10 @@ import org.http4s._ object MaxActiveRequests { + // TODO (ce3-ra): Sync + MonadCancel + @deprecated(message = "Please use forHttpApp instead.", since = "0.21.14") - def httpApp[F[_]: Concurrent]( + def httpApp[F[_]: Async]( maxActive: Long, defaultResp: Response[F] = Response[F](status = Status.ServiceUnavailable) ): F[Kleisli[F, Request[F], Response[F]] => Kleisli[F, Request[F], Response[F]]] = @@ -35,11 +37,12 @@ object MaxActiveRequests { def inHttpApp[G[_], F[_]]( maxActive: Long, defaultResp: Response[F] = Response[F](status = Status.ServiceUnavailable) - )(implicit F: Sync[F], G: Concurrent[G]) - : G[Kleisli[F, Request[F], Response[F]] => Kleisli[F, Request[F], Response[F]]] = + )(implicit + F: Async[F], + G: Sync[G]): G[Kleisli[F, Request[F], Response[F]] => Kleisli[F, Request[F], Response[F]]] = forHttpApp2[G, F](maxActive, defaultResp) - def forHttpApp[F[_]: Sync]( + def forHttpApp[F[_]: Async]( maxActive: Long, defaultResp: Response[F] = Response[F](status = Status.ServiceUnavailable) ): F[Kleisli[F, Request[F], Response[F]] => Kleisli[F, Request[F], Response[F]]] = @@ -49,7 +52,7 @@ object MaxActiveRequests { maxActive: Long, defaultResp: Response[F] = Response[F](status = Status.ServiceUnavailable) )(implicit - F: Sync[F], + F: Async[F], G: Sync[G]): G[Kleisli[F, Request[F], Response[F]] => Kleisli[F, Request[F], Response[F]]] = ConcurrentRequests .app2[G, F]( @@ -66,7 +69,7 @@ object MaxActiveRequests { }))) @deprecated(message = "Please use forHttpRoutes instead.", since = "0.21.14") - def httpRoutes[F[_]: Concurrent]( + def httpRoutes[F[_]: Async]( maxActive: Long, defaultResp: Response[F] = Response[F](status = Status.ServiceUnavailable) ): F[Kleisli[OptionT[F, *], Request[F], Response[F]] => Kleisli[ @@ -78,13 +81,13 @@ object MaxActiveRequests { def inHttpRoutes[G[_], F[_]]( maxActive: Long, defaultResp: Response[F] = Response[F](status = Status.ServiceUnavailable) - )(implicit F: Sync[F], G: Concurrent[G]): G[Kleisli[ + )(implicit F: Async[F], G: Sync[G]): G[Kleisli[OptionT[F, *], Request[F], Response[F]] => Kleisli[ OptionT[F, *], Request[F], - Response[F]] => Kleisli[OptionT[F, *], Request[F], Response[F]]] = + Response[F]]] = forHttpRoutes2[G, F](maxActive, defaultResp) - def forHttpRoutes[F[_]: Sync]( + def forHttpRoutes[F[_]: Async]( maxActive: Long, defaultResp: Response[F] = Response[F](status = Status.ServiceUnavailable) ): F[Kleisli[OptionT[F, *], Request[F], Response[F]] => Kleisli[ @@ -96,7 +99,7 @@ object MaxActiveRequests { def forHttpRoutes2[G[_], F[_]]( maxActive: Long, defaultResp: Response[F] = Response[F](status = Status.ServiceUnavailable) - )(implicit F: Sync[F], G: Sync[G]): G[Kleisli[OptionT[F, *], Request[F], Response[F]] => Kleisli[ + )(implicit F: Async[F], G: Sync[G]): G[Kleisli[OptionT[F, *], Request[F], Response[F]] => Kleisli[ OptionT[F, *], Request[F], Response[F]]] = diff --git a/server/src/main/scala/org/http4s/server/middleware/Metrics.scala b/server/src/main/scala/org/http4s/server/middleware/Metrics.scala index 13fdef6f61e..c5acfe7b72b 100644 --- a/server/src/main/scala/org/http4s/server/middleware/Metrics.scala +++ b/server/src/main/scala/org/http4s/server/middleware/Metrics.scala @@ -17,10 +17,9 @@ package org.http4s.server.middleware import cats.data.Kleisli -import cats.effect.{Clock, ExitCase, Sync} +import cats.effect.Clock +import cats.effect.kernel._ import cats.syntax.all._ -import java.util.concurrent.TimeUnit - import org.http4s._ import org.http4s.metrics.MetricsOps import org.http4s.metrics.TerminationType.{Abnormal, Canceled, Error} @@ -58,30 +57,52 @@ object Metrics { classifierF: Request[F] => Option[String] = { (_: Request[F]) => None } - )(routes: HttpRoutes[F])(implicit F: Sync[F], clock: Clock[F]): HttpRoutes[F] = + )(routes: HttpRoutes[F])(implicit F: Clock[F], C: MonadCancel[F, Throwable]): HttpRoutes[F] = + effect[F](ops, emptyResponseHandler, errorResponseHandler, classifierF(_).pure[F])(routes) + + /** A server middleware capable of recording metrics + * + * Same as [[apply]], but can classify requests effectually, e.g. performing side-effects. + * Failed attempt to classify the request (e.g. failing with `F.raiseError`) leads to not recording metrics for that request. + * + * @note Compiling the request body in `classifierF` is unsafe, unless you are using some caching middleware. + * + * @param ops a algebra describing the metrics operations + * @param emptyResponseHandler an optional http status to be registered for requests that do not match + * @param errorResponseHandler a function that maps a [[java.lang.Throwable]] to an optional http status code to register + * @param classifierF a function that allows to add a classifier that can be customized per request + * @return the metrics middleware + */ + def effect[F[_]]( + ops: MetricsOps[F], + emptyResponseHandler: Option[Status] = Status.NotFound.some, + errorResponseHandler: Throwable => Option[Status] = _ => Status.InternalServerError.some, + classifierF: Request[F] => F[Option[String]] + )(routes: HttpRoutes[F])(implicit F: Clock[F], C: MonadCancel[F, Throwable]): HttpRoutes[F] = BracketRequestResponse.bracketRequestResponseCaseRoutes_[F, MetricsRequestContext, Status] { (request: Request[F]) => - val classifier: Option[String] = classifierF(request) - ops.increaseActiveRequests(classifier) *> - clock - .monotonic(TimeUnit.NANOSECONDS) - .map(startTime => - ContextRequest(MetricsRequestContext(request.method, startTime, classifier), request)) - } { case (context, maybeStatus, exitCase) => + classifierF(request).flatMap { classifier => + ops.increaseActiveRequests(classifier) *> + F.monotonic + .map(startTime => + ContextRequest( + MetricsRequestContext(request.method, startTime.toNanos, classifier), + request)) + } + } { case (context, maybeStatus, outcome) => // Decrease active requests _first_ in case any of the other effects // trigger an error. This differs from the < 0.21.14 semantics, which // decreased it _after_ the other effects. This may have been the // reason the active requests counter was reported to have drifted. ops.decreaseActiveRequests(context.classifier) *> - clock - .monotonic(TimeUnit.NANOSECONDS) - .map(endTime => endTime - context.startTime) + F.monotonic + .map(endTime => endTime.toNanos - context.startTime) .flatMap(totalTime => - (exitCase match { - case ExitCase.Completed => + outcome match { + case Outcome.Succeeded(_) => (maybeStatus <+> emptyResponseHandler).traverse_(status => ops.recordTotalTime(context.method, status, totalTime, context.classifier)) - case ExitCase.Error(e) => + case Outcome.Errored(e) => maybeStatus.fold { // If an error occurred, and the status is empty, this means // that an error occurred before the routes could generate a @@ -98,21 +119,21 @@ object Metrics { // to invoke it here. ops.recordAbnormalTermination(totalTime, Abnormal(e), context.classifier) *> ops.recordTotalTime(context.method, status, totalTime, context.classifier)) - case ExitCase.Canceled => + case Outcome.Canceled() => ops.recordAbnormalTermination(totalTime, Canceled, context.classifier) - })) - }(F)( + }) + }(C)( Kleisli((contextRequest: ContextRequest[F, MetricsRequestContext]) => routes .run(contextRequest.req) .semiflatMap(response => - clock - .monotonic(TimeUnit.NANOSECONDS) - .map(now => now - contextRequest.context.startTime) + F.monotonic + .map(now => now.toNanos - contextRequest.context.startTime) .flatTap(headerTime => ops.recordHeadersTime( contextRequest.context.method, headerTime, - contextRequest.context.classifier)) *> F.pure( + contextRequest.context.classifier)) *> C.pure( ContextResponse(response.status, response))))) + } diff --git a/server/src/main/scala/org/http4s/server/middleware/PushSupport.scala b/server/src/main/scala/org/http4s/server/middleware/PushSupport.scala index 7a3346f5b6e..18eff692e75 100644 --- a/server/src/main/scala/org/http4s/server/middleware/PushSupport.scala +++ b/server/src/main/scala/org/http4s/server/middleware/PushSupport.scala @@ -20,7 +20,7 @@ package middleware import cats.Monad import cats.data.Kleisli -import cats.effect.IO +import cats.effect.SyncIO import cats.syntax.all._ import org.log4s.getLogger import org.typelevel.vault._ @@ -109,11 +109,12 @@ object PushSupport { private[PushSupport] final case class PushLocation(location: String, cascade: Boolean) private[http4s] final case class PushResponse[F[_]](location: String, resp: Response[F]) - private[PushSupport] val pushLocationKey = Key.newKey[IO, Vector[PushLocation]].unsafeRunSync() + private[PushSupport] val pushLocationKey = + Key.newKey[SyncIO, Vector[PushLocation]].unsafeRunSync() private[http4s] def pushResponsesKey[F[_]]: Key[F[Vector[PushResponse[F]]]] = Keys.PushResponses.asInstanceOf[Key[F[Vector[PushResponse[F]]]]] private[this] object Keys { - val PushResponses: Key[Any] = Key.newKey[IO, Any].unsafeRunSync() + val PushResponses: Key[Any] = Key.newKey[SyncIO, Any].unsafeRunSync() } } diff --git a/server/src/main/scala/org/http4s/server/middleware/RequestId.scala b/server/src/main/scala/org/http4s/server/middleware/RequestId.scala index bcddd348d70..58e4251e90c 100644 --- a/server/src/main/scala/org/http4s/server/middleware/RequestId.scala +++ b/server/src/main/scala/org/http4s/server/middleware/RequestId.scala @@ -22,7 +22,7 @@ package middleware import cats.{FlatMap, ~>} import cats.arrow.FunctionK import cats.data.{Kleisli, NonEmptyList, OptionT} -import cats.effect.{IO, Sync} +import cats.effect.{Sync, SyncIO} import cats.syntax.all._ import org.typelevel.ci._ import org.typelevel.vault.Key @@ -36,7 +36,7 @@ object RequestId { private[this] val requestIdHeader = ci"X-Request-ID" - val requestIdAttrKey: Key[String] = Key.newKey[IO, String].unsafeRunSync() + val requestIdAttrKey: Key[String] = Key.newKey[SyncIO, String].unsafeRunSync() def apply[G[_], F[_]](http: Http[G, F])(implicit G: Sync[G]): Http[G, F] = apply(requestIdHeader)(http) diff --git a/server/src/main/scala/org/http4s/server/middleware/RequestLogger.scala b/server/src/main/scala/org/http4s/server/middleware/RequestLogger.scala index a3c6a767eb3..8f78d9cee6c 100644 --- a/server/src/main/scala/org/http4s/server/middleware/RequestLogger.scala +++ b/server/src/main/scala/org/http4s/server/middleware/RequestLogger.scala @@ -21,14 +21,12 @@ package middleware import cats.~> import cats.arrow.FunctionK import cats.data.{Kleisli, OptionT} -import cats.effect.{BracketThrow, Concurrent, ExitCase, Sync} +import cats.effect.kernel.{Async, MonadCancelThrow, Outcome, Sync} import cats.effect.implicits._ -import cats.effect.concurrent.Ref import cats.syntax.all._ import fs2.{Chunk, Stream} import org.log4s.getLogger import org.typelevel.ci.CIString -import cats.effect.Sync._ /** Simple Middleware for Logging Requests As They Are Processed */ @@ -42,8 +40,8 @@ object RequestLogger { redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, logAction: Option[String => F[Unit]] = None )(http: Http[G, F])(implicit - F: Concurrent[F], - G: BracketThrow[G] + F: Async[F], + G: MonadCancelThrow[G] ): Http[G, F] = impl[G, F](logHeaders, Left(logBody), fk, redactHeadersWhen, logAction)(http) @@ -54,8 +52,8 @@ object RequestLogger { redactHeadersWhen: CIString => Boolean, logAction: Option[String => F[Unit]] )(http: Http[G, F])(implicit - F: Concurrent[F], - G: BracketThrow[G] + F: Async[F], + G: MonadCancelThrow[G] ): Http[G, F] = { val log = logAction.fold { (s: String) => Sync[F].delay(logger.info(s)) @@ -83,12 +81,11 @@ object RequestLogger { // As None Is Successful, but we oly want to log on Some http(req) .guaranteeCase { - case ExitCase.Canceled => fk(logAct) - case ExitCase.Error(_) => fk(logAct) - case ExitCase.Completed => G.unit + case Outcome.Succeeded(_) => G.unit + case _ => fk(logAct) } <* fk(logAct) } else - fk(Ref[F].of(Vector.empty[Chunk[Byte]])) + fk(F.ref(Vector.empty[Chunk[Byte]])) .flatMap { vec => val newBody = Stream .eval(vec.get) @@ -98,15 +95,16 @@ object RequestLogger { val changedRequest = req.withBodyStream( req.body // Cannot Be Done Asynchronously - Otherwise All Chunks May Not Be Appended Previous to Finalization - .observe(_.chunks.flatMap(c => Stream.eval_(vec.update(_ :+ c)))) + .observe(_.chunks.flatMap(c => Stream.exec(vec.update(_ :+ c)))) ) def logRequest: F[Unit] = logMessage(req.withBodyStream(newBody)) val response: G[Response[F]] = http(changedRequest) .guaranteeCase { - case ExitCase.Completed => G.unit + case Outcome.Succeeded(_) => G.unit case _ => fk(logRequest) + } .map(resp => resp.withBodyStream(resp.body.onFinalizeWeak(logRequest))) response @@ -114,7 +112,7 @@ object RequestLogger { } } - def httpApp[F[_]: Concurrent]( + def httpApp[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -122,7 +120,7 @@ object RequestLogger { )(httpApp: HttpApp[F]): HttpApp[F] = apply(logHeaders, logBody, FunctionK.id[F], redactHeadersWhen, logAction)(httpApp) - def httpRoutes[F[_]: Concurrent]( + def httpRoutes[F[_]: Async]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -130,7 +128,7 @@ object RequestLogger { )(httpRoutes: HttpRoutes[F]): HttpRoutes[F] = apply(logHeaders, logBody, OptionT.liftK[F], redactHeadersWhen, logAction)(httpRoutes) - def httpAppLogBodyText[F[_]: Concurrent]( + def httpAppLogBodyText[F[_]: Async]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -138,7 +136,7 @@ object RequestLogger { )(httpApp: HttpApp[F]): HttpApp[F] = impl[F, F](logHeaders, Right(logBody), FunctionK.id[F], redactHeadersWhen, logAction)(httpApp) - def httpRoutesLogBodyText[F[_]: Concurrent]( + def httpRoutesLogBodyText[F[_]: Async]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, diff --git a/server/src/main/scala/org/http4s/server/middleware/ResponseLogger.scala b/server/src/main/scala/org/http4s/server/middleware/ResponseLogger.scala index e016841403e..2a42ee41b0a 100644 --- a/server/src/main/scala/org/http4s/server/middleware/ResponseLogger.scala +++ b/server/src/main/scala/org/http4s/server/middleware/ResponseLogger.scala @@ -21,10 +21,8 @@ package middleware import cats.~> import cats.arrow.FunctionK import cats.data.{Kleisli, OptionT} -import cats.effect.{BracketThrow, Concurrent, ExitCase, Sync} -import cats.effect.implicits._ -import cats.effect.Sync._ -import cats.effect.concurrent.Ref +import cats.effect.kernel.{Async, MonadCancelThrow, Outcome, Sync} +import cats.effect.syntax.all._ import cats.syntax.all._ import fs2.{Chunk, Stream} import org.log4s.getLogger @@ -41,8 +39,8 @@ object ResponseLogger { fk: F ~> G, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, logAction: Option[String => F[Unit]] = None)(http: Kleisli[G, A, Response[F]])(implicit - G: BracketThrow[G], - F: Concurrent[F]): Kleisli[G, A, Response[F]] = + G: MonadCancelThrow[G], + F: Async[F]): Kleisli[G, A, Response[F]] = impl[G, F, A](logHeaders, Left(logBody), fk, redactHeadersWhen, logAction)(http) private[server] def impl[G[_], F[_], A]( @@ -51,8 +49,8 @@ object ResponseLogger { fk: F ~> G, redactHeadersWhen: CIString => Boolean, logAction: Option[String => F[Unit]])(http: Kleisli[G, A, Response[F]])(implicit - G: BracketThrow[G], - F: Concurrent[F]): Kleisli[G, A, Response[F]] = { + G: MonadCancelThrow[G], + F: Async[F]): Kleisli[G, A, Response[F]] = { val fallback: String => F[Unit] = s => Sync[F].delay(logger.info(s)) val log = logAction.fold(fallback)(identity) @@ -78,7 +76,7 @@ object ResponseLogger { logMessage(response) .as(response) else - Ref[F].of(Vector.empty[Chunk[Byte]]).map { vec => + F.ref(Vector.empty[Chunk[Byte]]).map { vec => val newBody = Stream .eval(vec.get) .flatMap(v => Stream.emits(v).covary[F]) @@ -87,7 +85,7 @@ object ResponseLogger { response.copy( body = response.body // Cannot Be Done Asynchronously - Otherwise All Chunks May Not Be Appended Previous to Finalization - .observe(_.chunks.flatMap(c => Stream.eval_(vec.update(_ :+ c)))) + .observe(_.chunks.flatMap(c => Stream.exec(vec.update(_ :+ c)))) .onFinalizeWeak { logMessage(response.withBodyStream(newBody)) } @@ -96,14 +94,15 @@ object ResponseLogger { fk(out) } .guaranteeCase { - case ExitCase.Error(t) => fk(log(s"service raised an error: ${t.getClass}")) - case ExitCase.Canceled => fk(log(s"service canceled response for request")) - case ExitCase.Completed => G.unit + case Outcome.Errored(t) => fk(log(s"service raised an error: ${t.getClass}")) + case Outcome.Canceled() => fk(log(s"service canceled response for request")) + case Outcome.Succeeded(_) => G.unit + } } } - def httpApp[F[_]: Concurrent, A]( + def httpApp[F[_]: Async, A]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -111,7 +110,7 @@ object ResponseLogger { httpApp: Kleisli[F, A, Response[F]]): Kleisli[F, A, Response[F]] = apply(logHeaders, logBody, FunctionK.id[F], redactHeadersWhen, logAction)(httpApp) - def httpAppLogBodyText[F[_]: Concurrent, A]( + def httpAppLogBodyText[F[_]: Async, A]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -120,7 +119,7 @@ object ResponseLogger { impl[F, F, A](logHeaders, Right(logBody), FunctionK.id[F], redactHeadersWhen, logAction)( httpApp) - def httpRoutes[F[_]: Concurrent, A]( + def httpRoutes[F[_]: Async, A]( logHeaders: Boolean, logBody: Boolean, redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, @@ -128,7 +127,7 @@ object ResponseLogger { httpRoutes: Kleisli[OptionT[F, *], A, Response[F]]): Kleisli[OptionT[F, *], A, Response[F]] = apply(logHeaders, logBody, OptionT.liftK[F], redactHeadersWhen, logAction)(httpRoutes) - def httpRoutesLogBodyText[F[_]: Concurrent, A]( + def httpRoutesLogBodyText[F[_]: Async, A]( logHeaders: Boolean, logBody: Stream[F, Byte] => Option[F[String]], redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains, diff --git a/server/src/main/scala/org/http4s/server/middleware/ResponseTiming.scala b/server/src/main/scala/org/http4s/server/middleware/ResponseTiming.scala index 594580c2cfd..b0f352de08e 100644 --- a/server/src/main/scala/org/http4s/server/middleware/ResponseTiming.scala +++ b/server/src/main/scala/org/http4s/server/middleware/ResponseTiming.scala @@ -43,10 +43,11 @@ object ResponseTiming { F: Sync[F], clock: Clock[F]): HttpApp[F] = Kleisli { req => + val getTime = clock.monotonic.map(_.toUnit(timeUnit).toLong) for { - before <- clock.monotonic(timeUnit) + before <- getTime resp <- http(req) - after <- clock.monotonic(timeUnit) + after <- getTime header = Header.Raw(headerName, s"${after - before}") } yield resp.putHeaders(header) } diff --git a/server/src/main/scala/org/http4s/server/middleware/Throttle.scala b/server/src/main/scala/org/http4s/server/middleware/Throttle.scala index f63d3aff72b..5b4154f4c60 100644 --- a/server/src/main/scala/org/http4s/server/middleware/Throttle.scala +++ b/server/src/main/scala/org/http4s/server/middleware/Throttle.scala @@ -19,11 +19,9 @@ package org.http4s.server.middleware import cats._ import org.http4s.{Http, Response, Status} import cats.data.Kleisli -import cats.effect.{Clock, Sync} -import cats.effect.concurrent.Ref +import cats.effect.kernel.Temporal import scala.concurrent.duration.FiniteDuration -import cats.syntax.all._ -import java.util.concurrent.TimeUnit.NANOSECONDS +import cats.implicits._ import scala.concurrent.duration._ /** Transform a service to reject any calls the go over a given rate. @@ -55,10 +53,9 @@ object Throttle { * @return A task to create the [[TokenBucket]]. */ def local[F[_]](capacity: Int, refillEvery: FiniteDuration)(implicit - F: Sync[F], - clock: Clock[F]): F[TokenBucket[F]] = { - def getTime = clock.monotonic(NANOSECONDS) - val bucket = getTime.flatMap(time => Ref[F].of((capacity.toDouble, time))) + F: Temporal[F]): F[TokenBucket[F]] = { + def getTime = F.monotonic.map(_.toNanos) + val bucket = getTime.flatMap(time => F.ref((capacity.toDouble, time))) bucket.map { counter => new TokenBucket[F] { @@ -102,8 +99,8 @@ object Throttle { * @param http the service to transform. * @return a task containing the transformed service. */ - def apply[F[_], G[_]](amount: Int, per: FiniteDuration)( - http: Http[F, G])(implicit F: Sync[F], timer: Clock[F]): F[Http[F, G]] = { + def apply[F[_], G[_]](amount: Int, per: FiniteDuration)(http: Http[F, G])(implicit + F: Temporal[F]): F[Http[F, G]] = { val refillFrequency = per / amount.toLong val createBucket = TokenBucket.local(amount, refillFrequency) createBucket.map(bucket => apply(bucket, defaultResponse[G] _)(http)) diff --git a/server/src/main/scala/org/http4s/server/middleware/Timeout.scala b/server/src/main/scala/org/http4s/server/middleware/Timeout.scala index 21402c2a43e..939b3846214 100644 --- a/server/src/main/scala/org/http4s/server/middleware/Timeout.scala +++ b/server/src/main/scala/org/http4s/server/middleware/Timeout.scala @@ -19,7 +19,7 @@ package server package middleware import cats.data.Kleisli -import cats.effect.{Concurrent, Timer} +import cats.effect.kernel.Temporal import cats.syntax.applicative._ import scala.concurrent.duration.FiniteDuration @@ -33,10 +33,8 @@ object Timeout { * @param service [[HttpRoutes]] to transform */ def apply[F[_], G[_], A](timeout: FiniteDuration, timeoutResponse: F[Response[G]])( - http: Kleisli[F, A, Response[G]])(implicit - F: Concurrent[F], - T: Timer[F]): Kleisli[F, A, Response[G]] = - http.mapF(Concurrent.timeoutTo(_, timeout, timeoutResponse)) + http: Kleisli[F, A, Response[G]])(implicit F: Temporal[F]): Kleisli[F, A, Response[G]] = + http.mapF(F.timeoutTo(_, timeout, timeoutResponse)) /** Transform the service to return a timeout response after the given * duration if the service has not yet responded. If the timeout @@ -47,8 +45,6 @@ object Timeout { * @param service [[HttpRoutes]] to transform */ def apply[F[_], G[_], A](timeout: FiniteDuration)(http: Kleisli[F, A, Response[G]])(implicit - F: Concurrent[F], - T: Timer[F] - ): Kleisli[F, A, Response[G]] = + F: Temporal[F]): Kleisli[F, A, Response[G]] = apply(timeout, Response.timeout[G].pure[F])(http) } diff --git a/server/src/main/scala/org/http4s/server/middleware/URITranslation.scala b/server/src/main/scala/org/http4s/server/middleware/URITranslation.scala deleted file mode 100644 index ab6a6f15a1e..00000000000 --- a/server/src/main/scala/org/http4s/server/middleware/URITranslation.scala +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2014 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 server -package middleware - -import cats.data.Kleisli - -@deprecated("Use org.http4s.server.middleware.TranslateUri instead", since = "0.18.16") -object URITranslation { - def translateRoot[F[_], G[_], B](prefix: String)( - @deprecatedName(Symbol("service")) http: Kleisli[F, Request[G], B]) - : Kleisli[F, Request[G], B] = { - val newCaret = prefix match { - case "/" => 0 - case x if x.startsWith("/") => x.length - case x => x.length + 1 - } - - http.local { (req: Request[G]) => - val oldCaret = req.attributes - .lookup(Request.Keys.PathInfoCaret) - .getOrElse(0) - req.withAttribute(Request.Keys.PathInfoCaret, oldCaret + newCaret) - } - } -} diff --git a/server/src/main/scala/org/http4s/server/middleware/UrlFormLifter.scala b/server/src/main/scala/org/http4s/server/middleware/UrlFormLifter.scala index 72f14433b23..b1ef2861525 100644 --- a/server/src/main/scala/org/http4s/server/middleware/UrlFormLifter.scala +++ b/server/src/main/scala/org/http4s/server/middleware/UrlFormLifter.scala @@ -31,7 +31,7 @@ import cats.~> * the body must be acessed through `multiParams`. */ object UrlFormLifter { - def apply[F[_]: Sync, G[_]: Sync](f: G ~> F)( + def apply[F[_]: Sync, G[_]: Concurrent](f: G ~> F)( http: Kleisli[F, Request[G], Response[G]], strictDecode: Boolean = false): Kleisli[F, Request[G], Response[G]] = Kleisli { req => diff --git a/server/src/main/scala/org/http4s/server/package.scala b/server/src/main/scala/org/http4s/server/package.scala index 7574ad6e51e..740288489f1 100644 --- a/server/src/main/scala/org/http4s/server/package.scala +++ b/server/src/main/scala/org/http4s/server/package.scala @@ -19,12 +19,12 @@ package org.http4s import cats.{Applicative, Monad} import cats.data.{Kleisli, OptionT} import cats.syntax.all._ -import cats.effect.IO -import org.typelevel.vault._ +import cats.effect.SyncIO import java.net.{InetAddress, InetSocketAddress} import org.http4s.headers.{Connection, `Content-Length`} import org.log4s.getLogger import org.typelevel.ci._ +import org.typelevel.vault._ import scala.concurrent.duration._ import scala.util.control.NonFatal @@ -79,7 +79,7 @@ package object server { object ServerRequestKeys { val SecureSession: Key[Option[SecureSession]] = - Key.newKey[IO, Option[SecureSession]].unsafeRunSync() + Key.newKey[SyncIO, Option[SecureSession]].unsafeRunSync() } /** A middleware is a function of one [[Service]] to another, possibly of a @@ -94,12 +94,6 @@ package object server { */ type Middleware[F[_], A, B, C, D] = Kleisli[F, A, B] => Kleisli[F, C, D] - object Middleware { - @deprecated("Construct manually instead", "0.18") - def apply[F[_], A, B, C, D](f: (C, Kleisli[F, A, B]) => F[D]): Middleware[F, A, B, C, D] = - service => Kleisli(req => f(req, service)) - } - /** An HTTP middleware converts an [[HttpRoutes]] to another. */ type HttpMiddleware[F[_]] = diff --git a/server/src/main/scala/org/http4s/server/staticcontent/CacheStrategy.scala b/server/src/main/scala/org/http4s/server/staticcontent/CacheStrategy.scala index 76d9bfbae8c..e671d0f0212 100644 --- a/server/src/main/scala/org/http4s/server/staticcontent/CacheStrategy.scala +++ b/server/src/main/scala/org/http4s/server/staticcontent/CacheStrategy.scala @@ -18,7 +18,7 @@ package org.http4s package server package staticcontent -import cats.effect.Sync +import cats.effect.Concurrent /** Cache the body of a [[Response]] for future use * @@ -29,5 +29,5 @@ import cats.effect.Sync trait CacheStrategy[F[_]] { /** Performs the caching operations */ - def cache(uriPath: Uri.Path, resp: Response[F])(implicit F: Sync[F]): F[Response[F]] + def cache(uriPath: Uri.Path, resp: Response[F])(implicit F: Concurrent[F]): F[Response[F]] } diff --git a/server/src/main/scala/org/http4s/server/staticcontent/FileService.scala b/server/src/main/scala/org/http4s/server/staticcontent/FileService.scala index 13756d4fbbb..46631dee929 100644 --- a/server/src/main/scala/org/http4s/server/staticcontent/FileService.scala +++ b/server/src/main/scala/org/http4s/server/staticcontent/FileService.scala @@ -19,7 +19,7 @@ package server package staticcontent import cats.data.{Kleisli, NonEmptyList, OptionT} -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.kernel.Async import cats.syntax.all._ import java.io.File import java.nio.file.{Files, LinkOption, NoSuchFileException, Path, Paths} @@ -47,26 +47,24 @@ object FileService { */ final case class Config[F[_]]( systemPath: String, - blocker: Blocker, pathCollector: PathCollector[F], pathPrefix: String, bufferSize: Int, cacheStrategy: CacheStrategy[F]) object Config { - def apply[F[_]: Sync: ContextShift]( + def apply[F[_]: Async]( systemPath: String, - blocker: Blocker, pathPrefix: String = "", bufferSize: Int = 50 * 1024, cacheStrategy: CacheStrategy[F] = NoopCacheStrategy[F]): Config[F] = { val pathCollector: PathCollector[F] = filesOnly - Config(systemPath, blocker, pathCollector, pathPrefix, bufferSize, cacheStrategy) + Config(systemPath, pathCollector, pathPrefix, bufferSize, cacheStrategy) } } /** Make a new [[org.http4s.HttpRoutes]] that serves static files. */ - private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Sync[F]): HttpRoutes[F] = { + private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Async[F]): HttpRoutes[F] = { object BadTraversal extends Exception with NoStackTrace Try(Paths.get(config.systemPath).toRealPath()) match { case Success(rootPath) => @@ -111,19 +109,18 @@ object FileService { } private def filesOnly[F[_]](file: File, config: Config[F], req: Request[F])(implicit - F: Sync[F], - cs: ContextShift[F]): OptionT[F, Response[F]] = + F: Async[F]): OptionT[F, Response[F]] = OptionT(F.defer { if (file.isDirectory) StaticFile - .fromFile(new File(file, "index.html"), config.blocker, Some(req)) + .fromFile(new File(file, "index.html"), Some(req)) .value else if (!file.isFile) F.pure(None) else OptionT(getPartialContentFile(file, config, req)) .orElse( StaticFile - .fromFile(file, config.bufferSize, config.blocker, Some(req), StaticFile.calcETag) + .fromFile(file, config.bufferSize, Some(req), StaticFile.calcETag) .map(_.putHeaders(AcceptRangeHeader)) ) .value @@ -137,8 +134,7 @@ object FileService { // Attempt to find a Range header and collect only the subrange of content requested private def getPartialContentFile[F[_]](file: File, config: Config[F], req: Request[F])(implicit - F: Sync[F], - cs: ContextShift[F]): F[Option[Response[F]]] = { + F: Async[F]): F[Option[Response[F]]] = { def nope: F[Option[Response[F]]] = F.delay(file.length()).map { size => Some( Response[F]( @@ -156,14 +152,7 @@ object FileService { val end = math.min(size - 1, e.getOrElse(size - 1)) // end is inclusive StaticFile - .fromFile( - file, - start, - end + 1, - config.bufferSize, - config.blocker, - Some(req), - StaticFile.calcETag) + .fromFile(file, start, end + 1, config.bufferSize, Some(req), StaticFile.calcETag) .map { resp => val hs = resp.headers .put(AcceptRangeHeader, `Content-Range`(SubRange(start, end), Some(size))) diff --git a/server/src/main/scala/org/http4s/server/staticcontent/MemoryCache.scala b/server/src/main/scala/org/http4s/server/staticcontent/MemoryCache.scala index bf78be50b73..c7ef9784854 100644 --- a/server/src/main/scala/org/http4s/server/staticcontent/MemoryCache.scala +++ b/server/src/main/scala/org/http4s/server/staticcontent/MemoryCache.scala @@ -18,7 +18,7 @@ package org.http4s package server package staticcontent -import cats.effect.Sync +import cats.effect.Concurrent import cats.syntax.functor._ import fs2.{Chunk, Stream} import java.util.concurrent.ConcurrentHashMap @@ -33,7 +33,8 @@ class MemoryCache[F[_]] extends CacheStrategy[F] { private[this] val logger = getLogger private val cacheMap = new ConcurrentHashMap[Uri.Path, Response[F]]() - override def cache(uriPath: Uri.Path, resp: Response[F])(implicit F: Sync[F]): F[Response[F]] = + override def cache(uriPath: Uri.Path, resp: Response[F])(implicit + F: Concurrent[F]): F[Response[F]] = if (resp.status == Status.Ok) Option(cacheMap.get(uriPath)) match { case Some(r) if r.headers.headers == resp.headers.headers => @@ -49,7 +50,7 @@ class MemoryCache[F[_]] extends CacheStrategy[F] { ////////////// private methods ////////////////////////////////////////////// private def collectResource(path: Uri.Path, resp: Response[F])(implicit - F: Sync[F]): F[Response[F]] = + F: Concurrent[F]): F[Response[F]] = resp .as[Chunk[Byte]] .map { chunk => diff --git a/server/src/main/scala/org/http4s/server/staticcontent/NoopCacheStrategy.scala b/server/src/main/scala/org/http4s/server/staticcontent/NoopCacheStrategy.scala index 543d58f8f83..d3d6efd4e26 100644 --- a/server/src/main/scala/org/http4s/server/staticcontent/NoopCacheStrategy.scala +++ b/server/src/main/scala/org/http4s/server/staticcontent/NoopCacheStrategy.scala @@ -18,11 +18,12 @@ package org.http4s package server package staticcontent -import cats.effect.Sync +import cats.effect.Concurrent /** Cache strategy that doesn't cache anything, ever. */ class NoopCacheStrategy[F[_]] extends CacheStrategy[F] { - override def cache(uriPath: Uri.Path, resp: Response[F])(implicit F: Sync[F]): F[Response[F]] = + override def cache(uriPath: Uri.Path, resp: Response[F])(implicit + F: Concurrent[F]): F[Response[F]] = F.pure(resp) } diff --git a/server/src/main/scala/org/http4s/server/staticcontent/ResourceService.scala b/server/src/main/scala/org/http4s/server/staticcontent/ResourceService.scala index 44132b37914..5d5ffcbcdf6 100644 --- a/server/src/main/scala/org/http4s/server/staticcontent/ResourceService.scala +++ b/server/src/main/scala/org/http4s/server/staticcontent/ResourceService.scala @@ -19,7 +19,7 @@ package server package staticcontent import cats.data.{Kleisli, OptionT} -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.Async import cats.syntax.all._ import java.nio.file.Paths import org.http4s.server.middleware.TranslateUri @@ -39,7 +39,6 @@ import scala.util.{Failure, Success, Try} */ class ResourceServiceBuilder[F[_]] private ( basePath: String, - blocker: Blocker, pathPrefix: String, bufferSize: Int, cacheStrategy: CacheStrategy[F], @@ -49,7 +48,6 @@ class ResourceServiceBuilder[F[_]] private ( private def copy( basePath: String = basePath, - blocker: Blocker = blocker, pathPrefix: String = pathPrefix, bufferSize: Int = bufferSize, cacheStrategy: CacheStrategy[F] = cacheStrategy, @@ -57,7 +55,6 @@ class ResourceServiceBuilder[F[_]] private ( classLoader: Option[ClassLoader] = classLoader): ResourceServiceBuilder[F] = new ResourceServiceBuilder[F]( basePath, - blocker, pathPrefix, bufferSize, cacheStrategy, @@ -65,9 +62,6 @@ class ResourceServiceBuilder[F[_]] private ( classLoader) def withBasePath(basePath: String): ResourceServiceBuilder[F] = copy(basePath = basePath) - - def withBlocker(blocker: Blocker): ResourceServiceBuilder[F] = copy(blocker = blocker) - def withPathPrefix(pathPrefix: String): ResourceServiceBuilder[F] = copy(pathPrefix = pathPrefix) @@ -82,7 +76,7 @@ class ResourceServiceBuilder[F[_]] private ( def withBufferSize(bufferSize: Int): ResourceServiceBuilder[F] = copy(bufferSize = bufferSize) - def toRoutes(implicit F: Sync[F], cs: ContextShift[F]): HttpRoutes[F] = { + def toRoutes(implicit F: Async[F]): HttpRoutes[F] = { val basePath = if (this.basePath.isEmpty) "/" else this.basePath object BadTraversal extends Exception with NoStackTrace @@ -105,7 +99,6 @@ class ResourceServiceBuilder[F[_]] private ( .flatMap { path => StaticFile.fromResource( path.toString, - blocker, Some(request), preferGzipped = preferGzipped, classLoader @@ -127,10 +120,9 @@ class ResourceServiceBuilder[F[_]] private ( } object ResourceServiceBuilder { - def apply[F[_]](basePath: String, blocker: Blocker): ResourceServiceBuilder[F] = + def apply[F[_]](basePath: String): ResourceServiceBuilder[F] = new ResourceServiceBuilder[F]( basePath = basePath, - blocker = blocker, pathPrefix = "", bufferSize = 50 * 1024, cacheStrategy = NoopCacheStrategy[F], @@ -152,7 +144,6 @@ object ResourceService { */ final case class Config[F[_]]( basePath: String, - blocker: Blocker, pathPrefix: String = "", bufferSize: Int = 50 * 1024, cacheStrategy: CacheStrategy[F] = NoopCacheStrategy[F], @@ -160,8 +151,7 @@ object ResourceService { /** Make a new [[org.http4s.HttpRoutes]] that serves static files. */ @deprecated("use ResourceServiceBuilder", "1.0.0-M1") - private[staticcontent] def apply[F[_]]( - config: Config[F])(implicit F: Sync[F], cs: ContextShift[F]): HttpRoutes[F] = { + private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Async[F]): HttpRoutes[F] = { val basePath = if (config.basePath.isEmpty) "/" else config.basePath object BadTraversal extends Exception with NoStackTrace @@ -184,7 +174,6 @@ object ResourceService { .flatMap { path => StaticFile.fromResource( path.toString, - config.blocker, Some(request), preferGzipped = config.preferGzipped ) diff --git a/server/src/main/scala/org/http4s/server/staticcontent/WebjarService.scala b/server/src/main/scala/org/http4s/server/staticcontent/WebjarService.scala index 22d4ee7e732..f12f44c3323 100644 --- a/server/src/main/scala/org/http4s/server/staticcontent/WebjarService.scala +++ b/server/src/main/scala/org/http4s/server/staticcontent/WebjarService.scala @@ -19,7 +19,7 @@ package server package staticcontent import cats.data.{Kleisli, OptionT} -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.kernel.Async import cats.syntax.all._ import java.nio.file.{Path, Paths} import org.http4s.internal.CollectionCompat.CollectionConverters._ @@ -34,7 +34,6 @@ import scala.util.control.NoStackTrace * @param preferGzipped prefer gzip compression format? */ class WebjarServiceBuilder[F[_]] private ( - blocker: Blocker, webjarAssetFilter: WebjarServiceBuilder.WebjarAssetFilter, cacheStrategy: CacheStrategy[F], classLoader: Option[ClassLoader], @@ -43,17 +42,11 @@ class WebjarServiceBuilder[F[_]] private ( import WebjarServiceBuilder.{WebjarAsset, WebjarAssetFilter, serveWebjarAsset} private def copy( - blocker: Blocker = blocker, webjarAssetFilter: WebjarAssetFilter = webjarAssetFilter, cacheStrategy: CacheStrategy[F] = cacheStrategy, classLoader: Option[ClassLoader] = classLoader, preferGzipped: Boolean = preferGzipped) = - new WebjarServiceBuilder[F]( - blocker, - webjarAssetFilter, - cacheStrategy, - classLoader, - preferGzipped) + new WebjarServiceBuilder[F](webjarAssetFilter, cacheStrategy, classLoader, preferGzipped) def withWebjarAssetFilter(webjarAssetFilter: WebjarAssetFilter): WebjarServiceBuilder[F] = copy(webjarAssetFilter = webjarAssetFilter) @@ -64,13 +57,10 @@ class WebjarServiceBuilder[F[_]] private ( def withClassLoader(classLoader: Option[ClassLoader]): WebjarServiceBuilder[F] = copy(classLoader = classLoader) - def withBlocker(blocker: Blocker): WebjarServiceBuilder[F] = - copy(blocker = blocker) - def withPreferGzipped(preferGzipped: Boolean): WebjarServiceBuilder[F] = copy(preferGzipped = preferGzipped) - def toRoutes(implicit F: Sync[F], cs: ContextShift[F]): HttpRoutes[F] = { + def toRoutes(implicit F: Async[F]): HttpRoutes[F] = { object BadTraversal extends Exception with NoStackTrace val Root = Paths.get("") Kleisli { @@ -87,7 +77,7 @@ class WebjarServiceBuilder[F[_]] private ( }) .subflatMap(toWebjarAsset) .filter(webjarAssetFilter) - .flatMap(serveWebjarAsset(blocker, cacheStrategy, classLoader, request, preferGzipped)(_)) + .flatMap(serveWebjarAsset(cacheStrategy, classLoader, request, preferGzipped)(_)) .recover { case BadTraversal => Response(Status.BadRequest) } @@ -126,9 +116,8 @@ class WebjarServiceBuilder[F[_]] private ( } object WebjarServiceBuilder { - def apply[F[_]](blocker: Blocker) = + def apply[F[_]] = new WebjarServiceBuilder( - blocker = blocker, webjarAssetFilter = _ => true, cacheStrategy = NoopCacheStrategy[F], classLoader = None, @@ -165,16 +154,15 @@ object WebjarServiceBuilder { * @param preferGzipped prefer gzip compression format? * @return Either the the Asset, if it exist, or Pass */ - private def serveWebjarAsset[F[_]: Sync: ContextShift]( - blocker: Blocker, + private def serveWebjarAsset[F[_]]( cacheStrategy: CacheStrategy[F], classLoader: Option[ClassLoader], request: Request[F], - preferGzipped: Boolean)(webjarAsset: WebjarAsset): OptionT[F, Response[F]] = + preferGzipped: Boolean)(webjarAsset: WebjarAsset)(implicit + F: Async[F]): OptionT[F, Response[F]] = StaticFile .fromResource( webjarAsset.pathInJar, - blocker, Some(request), classloader = classLoader, preferGzipped = preferGzipped) @@ -190,7 +178,6 @@ object WebjarService { * @param cacheStrategy strategy to use for caching purposes. Default to no caching. */ final case class Config[F[_]]( - blocker: Blocker, filter: WebjarAssetFilter = _ => true, cacheStrategy: CacheStrategy[F] = NoopCacheStrategy[F]) @@ -221,8 +208,8 @@ object WebjarService { * @param config The configuration for this service * @return The HttpRoutes */ - @deprecated("use WebjarServiceBuilder", "0.22.0-M1") - def apply[F[_]](config: Config[F])(implicit F: Sync[F], cs: ContextShift[F]): HttpRoutes[F] = { + @deprecated("use WebjarServiceBuilder", "1.0.0-M1") + def apply[F[_]](config: Config[F])(implicit F: Async[F]): HttpRoutes[F] = { object BadTraversal extends Exception with NoStackTrace val Root = Paths.get("") Kleisli { @@ -270,9 +257,9 @@ object WebjarService { * @param request The Request * @return Either the the Asset, if it exist, or Pass */ - private def serveWebjarAsset[F[_]: Sync: ContextShift](config: Config[F], request: Request[F])( + private def serveWebjarAsset[F[_]: Async](config: Config[F], request: Request[F])( webjarAsset: WebjarAsset): OptionT[F, Response[F]] = StaticFile - .fromResource(webjarAsset.pathInJar, config.blocker, Some(request)) + .fromResource(webjarAsset.pathInJar, Some(request)) .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) } diff --git a/server/src/main/scala/org/http4s/server/staticcontent/package.scala b/server/src/main/scala/org/http4s/server/staticcontent/package.scala index d32e4c24675..0d4a1158bcc 100644 --- a/server/src/main/scala/org/http4s/server/staticcontent/package.scala +++ b/server/src/main/scala/org/http4s/server/staticcontent/package.scala @@ -17,7 +17,7 @@ package org.http4s package server -import cats.effect.{Blocker, ContextShift, Sync} +import cats.effect.kernel.{Async} import org.http4s.headers.`Accept-Ranges` /** Helpers for serving static content from http4s @@ -28,26 +28,16 @@ import org.http4s.headers.`Accept-Ranges` package object staticcontent { /** Make a new [[org.http4s.HttpRoutes]] that serves static files, possibly from the classpath. */ - def resourceServiceBuilder[F[_]](basePath: String, blocker: Blocker): ResourceServiceBuilder[F] = - ResourceServiceBuilder[F](basePath, blocker) - - /** Make a new [[org.http4s.HttpRoutes]] that serves static files, possibly from the classpath. */ - @deprecated("use resourceServiceBuilder", "0.22.0-M1") - def resourceService[F[_]: Sync: ContextShift](config: ResourceService.Config[F]): HttpRoutes[F] = - ResourceService(config) + def resourceServiceBuilder[F[_]](basePath: String): ResourceServiceBuilder[F] = + ResourceServiceBuilder[F](basePath) /** Make a new [[org.http4s.HttpRoutes]] that serves static files. */ - def fileService[F[_]: Sync](config: FileService.Config[F]): HttpRoutes[F] = + def fileService[F[_]: Async](config: FileService.Config[F]): HttpRoutes[F] = FileService(config) /** Make a new [[org.http4s.HttpRoutes]] that serves static files from webjars */ - def webjarServiceBuilder[F[_]](blocker: Blocker): WebjarServiceBuilder[F] = - WebjarServiceBuilder[F](blocker) - - /** Make a new [[org.http4s.HttpRoutes]] that serves static files from webjars */ - @deprecated("use webjarServiceBuilder", "0.22.0-M1") - def webjarService[F[_]: Sync: ContextShift](config: WebjarService.Config[F]): HttpRoutes[F] = - WebjarService(config) + def webjarServiceBuilder[F[_]]: WebjarServiceBuilder[F] = + WebjarServiceBuilder[F] private[staticcontent] val AcceptRangeHeader = `Accept-Ranges`(RangeUnit.Bytes) diff --git a/server/src/test/scala/org/http4s/server/middleware/BracketRequestResponseSuite.scala b/server/src/test/scala/org/http4s/server/middleware/BracketRequestResponseSuite.scala index f36d6b6d6fd..5bb8aeb0369 100644 --- a/server/src/test/scala/org/http4s/server/middleware/BracketRequestResponseSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/BracketRequestResponseSuite.scala @@ -19,7 +19,6 @@ package org.http4s.server.middleware import fs2.Stream import cats.data._ import cats.effect._ -import cats.effect.concurrent._ import org.http4s._ import org.http4s.server._ @@ -32,8 +31,9 @@ final class BracketRequestResponseSuite extends Http4sSuite { releaseRef <- Ref.of[IO, Long](0L) middleware = BracketRequestResponse.bracketRequestResponseCaseRoutes[IO, Long]( - acquireRef.updateAndGet(_ + 1L)) { case (_, ec) => - IO(assertEquals(ec, ExitCase.Completed)) *> releaseRef.update(_ + 1L) + acquireRef.updateAndGet(_ + 1L)) { case (_, oc) => + IO(assertEquals(oc, Outcome.succeeded[IO, Throwable, Unit](IO.unit))) *> releaseRef + .update(_ + 1L) } routes = middleware( Kleisli((contextRequest: ContextRequest[IO, Long]) => @@ -60,7 +60,8 @@ final class BracketRequestResponseSuite extends Http4sSuite { middleware = BracketRequestResponse.bracketRequestResponseCaseRoutes[IO, Long]( acquireRef.updateAndGet(_ + 1L)) { case (_, ec) => - IO(assertEquals(ec, ExitCase.Error(error))) *> releaseRef.update(_ + 1L) + IO(assertEquals(ec, Outcome.errored[IO, Throwable, Unit](error))) *> releaseRef.update( + _ + 1L) } routes = middleware(Kleisli(Function.const(OptionT.liftF(IO.raiseError(error))))) response <- routes.run(Request[IO]()).value.attempt @@ -80,7 +81,8 @@ final class BracketRequestResponseSuite extends Http4sSuite { middleware = BracketRequestResponse.bracketRequestResponseCaseRoutes[IO, Long]( acquireRef.updateAndGet(_ + 1L)) { case (_, ec) => - IO(assertEquals(ec, ExitCase.Completed)) *> releaseRef.update(_ + 1L) + IO(assertEquals(ec, Outcome.succeeded[IO, Throwable, Unit](IO.unit))) *> releaseRef + .update(_ + 1L) } routes = middleware(Kleisli(Function.const(OptionT.none))) response <- routes.run(Request[IO]()).value @@ -101,7 +103,8 @@ final class BracketRequestResponseSuite extends Http4sSuite { middleware = BracketRequestResponse.bracketRequestResponseCaseRoutes[IO, Long]( acquireRef.updateAndGet(_ + 1L)) { case (_, ec) => - IO(assertEquals(ec, ExitCase.Completed)) *> releaseRef.update(_ + 1L) + IO(assertEquals(ec, Outcome.succeeded[IO, Throwable, Unit](IO.unit))) *> releaseRef + .update(_ + 1L) } routes = middleware( Kleisli((contextRequest: ContextRequest[IO, Long]) => @@ -161,7 +164,8 @@ final class BracketRequestResponseSuite extends Http4sSuite { middleware = BracketRequestResponse.bracketRequestResponseCaseRoutes[IO, Long]( acquireRef.updateAndGet(_ + 1L)) { case (_, ec) => - IO(assertEquals(ec, ExitCase.Error(error))) *> releaseRef.update(_ + 1L) + IO(assertEquals(ec, Outcome.errored[IO, Throwable, Unit](error))) *> releaseRef.update( + _ + 1L) } routes = middleware( Kleisli( @@ -203,7 +207,8 @@ final class BracketRequestResponseSuite extends Http4sSuite { middleware = BracketRequestResponse.bracketRequestResponseCaseRoutes[IO, Long]( acquireRef.updateAndGet(_ + 1L)) { case (_, ec) => - IO(assertEquals(ec, ExitCase.Completed)) *> releaseRef.update(_ + 1L) *> IO + IO(assertEquals(ec, Outcome.succeeded[IO, Throwable, Unit](IO.unit))) *> releaseRef + .update(_ + 1L) *> IO .raiseError[Unit](error) } routes = middleware( diff --git a/server/src/test/scala/org/http4s/server/middleware/DateSuite.scala b/server/src/test/scala/org/http4s/server/middleware/DateSuite.scala index 4ea36614a3f..72c0b702423 100644 --- a/server/src/test/scala/org/http4s/server/middleware/DateSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/DateSuite.scala @@ -16,21 +16,20 @@ package org.http4s.server.middleware -import cats.data.OptionT -import cats.syntax.all._ +import cats.implicits._ import cats.effect._ import org.http4s._ import org.http4s.headers.{Date => HDate} +import org.http4s.syntax.all._ class DateSuite extends Http4sSuite { - implicit val timer: Timer[IO] = Http4sSuite.TestTimer val service: HttpRoutes[IO] = HttpRoutes.of[IO] { case _ => Response[IO](Status.Ok).pure[IO] } // Hack for https://github.com/typelevel/cats-effect/pull/682 - val testService = Date(service)(Sync[OptionT[IO, *]], Clock.deriveOptionT[IO]) + val testService = Date(service) val testApp = Date(service.orNotFound) val req = Request[IO]() diff --git a/server/src/test/scala/org/http4s/server/middleware/DefaultHeadSuite.scala b/server/src/test/scala/org/http4s/server/middleware/DefaultHeadSuite.scala index 12b36b9abd2..a697a3da981 100644 --- a/server/src/test/scala/org/http4s/server/middleware/DefaultHeadSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/DefaultHeadSuite.scala @@ -19,7 +19,7 @@ package server package middleware import cats.effect._ -import cats.effect.concurrent.Ref +import cats.effect.kernel.Ref import cats.implicits._ import fs2.Stream import org.http4s.dsl.io._ diff --git a/server/src/test/scala/org/http4s/server/middleware/EntityLimiterSuite.scala b/server/src/test/scala/org/http4s/server/middleware/EntityLimiterSuite.scala index fe2f4fc99bf..5f995a334a6 100644 --- a/server/src/test/scala/org/http4s/server/middleware/EntityLimiterSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/EntityLimiterSuite.scala @@ -33,7 +33,7 @@ class EntityLimiterSuite extends Http4sSuite { case r if r.pathInfo == path"/echo" => r.decode[String](Response[IO](Ok).withEntity(_).pure[IO]) } - val b = chunk(Chunk.bytes("hello".getBytes(StandardCharsets.UTF_8))) + val b = chunk(Chunk.array("hello".getBytes(StandardCharsets.UTF_8))) test("Allow reasonable entities") { EntityLimiter(routes, 100) diff --git a/server/src/test/scala/org/http4s/server/middleware/ErrorActionSuite.scala b/server/src/test/scala/org/http4s/server/middleware/ErrorActionSuite.scala index 3829b72bdf2..d57382b0a74 100644 --- a/server/src/test/scala/org/http4s/server/middleware/ErrorActionSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/ErrorActionSuite.scala @@ -16,8 +16,7 @@ package org.http4s.server.middleware -import cats.effect.IO -import cats.effect.concurrent.Ref +import cats.effect.{IO, Ref} import com.comcast.ip4s.{Ipv4Address, Port, SocketAddress} import org.http4s._ import org.http4s.Request.Connection diff --git a/server/src/test/scala/org/http4s/server/middleware/LoggerSuite.scala b/server/src/test/scala/org/http4s/server/middleware/LoggerSuite.scala index eac3281b0ff..7dfab2e91b4 100644 --- a/server/src/test/scala/org/http4s/server/middleware/LoggerSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/LoggerSuite.scala @@ -40,7 +40,7 @@ class LoggerSuite extends Http4sSuite { def testResource = getClass.getResourceAsStream("/testresource.txt") def body: EntityBody[IO] = - readInputStream[IO](IO.pure(testResource), 4096, testBlocker) + readInputStream[IO](IO.pure(testResource), 4096) val expectedBody: String = Source.fromInputStream(testResource).mkString diff --git a/server/src/test/scala/org/http4s/server/middleware/MaxActiveRequestsSuite.scala b/server/src/test/scala/org/http4s/server/middleware/MaxActiveRequestsSuite.scala index 34b68b38d63..1cce39bee7c 100644 --- a/server/src/test/scala/org/http4s/server/middleware/MaxActiveRequestsSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/MaxActiveRequestsSuite.scala @@ -19,7 +19,6 @@ package org.http4s.server.middleware import cats.implicits._ import cats.effect._ import cats.data._ -import cats.effect.concurrent._ import org.http4s._ import org.http4s.syntax.all._ diff --git a/server/src/test/scala/org/http4s/server/middleware/ResponseTimingSuite.scala b/server/src/test/scala/org/http4s/server/middleware/ResponseTimingSuite.scala index c4bf6d0c4f7..2fe9927317e 100644 --- a/server/src/test/scala/org/http4s/server/middleware/ResponseTimingSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/ResponseTimingSuite.scala @@ -16,15 +16,17 @@ package org.http4s.server.middleware -import cats.syntax.all._ +import cats.implicits._ import cats.effect._ -import cats.effect.concurrent.Ref +import cats.effect.Ref import org.http4s._ import org.http4s.dsl.io._ import org.typelevel.ci._ import org.http4s.syntax.all._ -import scala.concurrent.duration.TimeUnit +import scala.concurrent.duration._ +import java.util.concurrent.TimeUnit +import cats.Applicative class ResponseTimingSuite extends Http4sSuite { import Sys.clock @@ -48,13 +50,17 @@ class ResponseTimingSuite extends Http4sSuite { } object Sys { - private val currentTime: Ref[IO, Long] = Ref.unsafe[IO, Long](System.currentTimeMillis()) + private val currentTime: Ref[IO, Long] = Ref.unsafe[IO, Long](0L) def tick(): IO[Long] = currentTime.modify(l => (l + 1L, l)) implicit val clock: Clock[IO] = new Clock[IO] { - override def realTime(unit: TimeUnit): IO[Long] = currentTime.get + override def applicative: Applicative[IO] = Applicative[IO] - override def monotonic(unit: TimeUnit): IO[Long] = currentTime.get + override def realTime: IO[FiniteDuration] = + currentTime.get.map(millis => FiniteDuration(millis, TimeUnit.MILLISECONDS)) + + override def monotonic: IO[FiniteDuration] = + currentTime.get.map(millis => FiniteDuration(millis, TimeUnit.MILLISECONDS)) } } diff --git a/server/src/test/scala/org/http4s/server/middleware/ThrottleSuite.scala b/server/src/test/scala/org/http4s/server/middleware/ThrottleSuite.scala index 2939ec8831c..7d45bccf1ad 100644 --- a/server/src/test/scala/org/http4s/server/middleware/ThrottleSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/ThrottleSuite.scala @@ -16,25 +16,23 @@ package org.http4s.server.middleware -import cats.effect.IO.ioEffect -import cats.effect.laws.util.TestContext -import cats.effect.{IO, Timer} +import cats.effect.IO import cats.implicits._ import org.http4s.{Http4sSuite, HttpApp, Request, Status} import org.http4s.syntax.all._ import org.http4s.dsl.io._ import org.http4s.server.middleware.Throttle._ import scala.concurrent.duration._ +import cats.effect.testkit.TestContext class ThrottleSuite extends Http4sSuite { test("LocalTokenBucket should contain initial number of tokens equal to specified capacity") { - val ctx = TestContext() - val munitTimer: Timer[IO] = ctx.timer[IO] + // val ctx = TestContext() val someRefillTime = 1234.milliseconds val capacity = 5 val createBucket = - TokenBucket.local[IO](capacity, someRefillTime)(ioEffect, munitTimer.clock) + TokenBucket.local[IO](capacity, someRefillTime) createBucket.flatMap { testee => val takeFiveTokens: IO[List[TokenAvailability]] = @@ -51,10 +49,10 @@ class ThrottleSuite extends Http4sSuite { val capacity = 1 val createBucket = - TokenBucket.local[IO](capacity, 100.milliseconds)(ioEffect, munitTimer.clock) + TokenBucket.local[IO](capacity, 100.milliseconds) val takeTokenAfterRefill = createBucket.flatMap { testee => - testee.takeToken *> munitTimer.sleep(101.milliseconds) *> + testee.takeToken *> IO.sleep(101.milliseconds) *> testee.takeToken } @@ -70,13 +68,13 @@ class ThrottleSuite extends Http4sSuite { val ctx = TestContext() val capacity = 5 val createBucket = - TokenBucket.local[IO](capacity, 100.milliseconds)(ioEffect, munitTimer.clock) + TokenBucket.local[IO](capacity, 100.milliseconds) val takeExtraToken = createBucket.flatMap { testee => val takeFiveTokens: IO[List[TokenAvailability]] = (1 to 5).toList.traverse { _ => testee.takeToken } - munitTimer.sleep(300.milliseconds) >> takeFiveTokens >> testee.takeToken + IO.sleep(300.milliseconds) >> takeFiveTokens >> testee.takeToken } takeExtraToken @@ -92,7 +90,7 @@ class ThrottleSuite extends Http4sSuite { "LocalTokenBucket should only return a single token when only one token available and there are multiple concurrent requests") { val capacity = 1 val createBucket = - TokenBucket.local[IO](capacity, 100.milliseconds)(ioEffect, munitTimer.clock) + TokenBucket.local[IO](capacity, 100.milliseconds) val takeTokensSimultaneously = createBucket.flatMap { testee => (1 to 5).toList.parTraverse(_ => testee.takeToken) @@ -110,10 +108,10 @@ class ThrottleSuite extends Http4sSuite { val ctx = TestContext() val capacity = 1 val createBucket = - TokenBucket.local[IO](capacity, 100.milliseconds)(ioEffect, munitTimer.clock) + TokenBucket.local[IO](capacity, 100.milliseconds) val takeTwoTokens = createBucket.flatMap { testee => - testee.takeToken *> munitTimer.sleep(75.milliseconds) *> testee.takeToken + testee.takeToken *> IO.sleep(75.milliseconds) *> testee.takeToken } takeTwoTokens.map { result => diff --git a/server/src/test/scala/org/http4s/server/middleware/TimeoutSuite.scala b/server/src/test/scala/org/http4s/server/middleware/TimeoutSuite.scala index 6e146d31b7b..61dc731fca1 100644 --- a/server/src/test/scala/org/http4s/server/middleware/TimeoutSuite.scala +++ b/server/src/test/scala/org/http4s/server/middleware/TimeoutSuite.scala @@ -34,9 +34,7 @@ class TimeoutSuite extends Http4sSuite { Ok("Fast") case _ -> Root / "never" => - IO.async[Response[IO]] { _ => - () - } + IO.never[Response[IO]] } val app = TimeoutMiddleware(5.milliseconds)(routes).orNotFound diff --git a/server/src/test/scala/org/http4s/server/staticcontent/FileServiceSuite.scala b/server/src/test/scala/org/http4s/server/staticcontent/FileServiceSuite.scala index 90bc838caac..59cab10312c 100644 --- a/server/src/test/scala/org/http4s/server/staticcontent/FileServiceSuite.scala +++ b/server/src/test/scala/org/http4s/server/staticcontent/FileServiceSuite.scala @@ -31,7 +31,8 @@ import org.http4s.server.middleware.TranslateUri class FileServiceSuite extends Http4sSuite with StaticContentShared { val defaultSystemPath = org.http4s.server.test.BuildInfo.test_resourceDirectory.getAbsolutePath val routes = fileService( - FileService.Config[IO](new File(getClass.getResource("/").toURI).getPath, testBlocker)) + FileService.Config[IO](new File(getClass.getResource("/").toURI).getPath) + ) test("Respect UriTranslation") { val app = TranslateUri("/foo")(routes).orNotFound @@ -72,7 +73,6 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { val s0 = fileService( FileService.Config[IO]( systemPath = defaultSystemPath, - blocker = testBlocker, pathPrefix = "/path-prefix" )) val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile @@ -91,8 +91,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { val req = Request[IO](uri = uri) val s0 = fileService( FileService.Config[IO]( - systemPath = systemPath.toString, - blocker = testBlocker + systemPath = systemPath.toString )) IO(file.exists()).assert *> s0.orNotFound(req).map(_.status).assertEquals(Status.BadRequest) @@ -117,8 +116,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { val req = Request[IO](uri = uri) val s0 = fileService( FileService.Config[IO]( - systemPath = Paths.get(defaultSystemPath).resolve("test").toString, - blocker = testBlocker + systemPath = Paths.get(defaultSystemPath).resolve("test").toString )) IO(file.exists()).assert *> s0.orNotFound(req).map(_.status).assertEquals(Status.NotFound) @@ -134,8 +132,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { val s0 = fileService( FileService.Config[IO]( systemPath = defaultSystemPath, - pathPrefix = "/prefix", - blocker = testBlocker + pathPrefix = "/prefix" )) IO(file.exists()).assert *> s0.orNotFound(req).map(_.status).assertEquals(Status.NotFound) @@ -155,7 +152,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { val relativePath = "symlink/org/http4s/server/staticcontent/FileServiceSuite.scala" val path = Paths.get(defaultSystemPath).resolve(relativePath) val file = path.toFile - val bytes = Chunk.bytes(Files.readAllBytes(path)) + val bytes = Chunk.array(Files.readAllBytes(path)) val uri = Uri.unsafeFromString("/" + relativePath) val req = Request[IO](uri = uri) @@ -172,7 +169,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { test("Return index.html if request points to ''") { val path = Paths.get(defaultSystemPath).resolve("testDir/").toAbsolutePath.toString - val s0 = fileService(FileService.Config[IO](systemPath = path, blocker = testBlocker)) + val s0 = fileService(FileService.Config[IO](systemPath = path)) val req = Request[IO](uri = uri"") s0.orNotFound(req) .flatMap { res => @@ -185,7 +182,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { test("Return index.html if request points to '/'") { val path = Paths.get(defaultSystemPath).resolve("testDir/").toAbsolutePath.toString - val s0 = fileService(FileService.Config[IO](systemPath = path, blocker = testBlocker)) + val s0 = fileService(FileService.Config[IO](systemPath = path)) val req = Request[IO](uri = uri"/") val rb = s0.orNotFound(req) @@ -217,7 +214,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { .flatMap(_.body.chunks) .compile .lastOrError - .assertEquals(Chunk.bytes(testResource.toArray.splitAt(4)._2)) *> + .assertEquals(Chunk.array(testResource.toArray.splitAt(4)._2)) *> routes.orNotFound(req).map(_.status).assertEquals(Status.PartialContent) } @@ -229,7 +226,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { .flatMap(_.body.chunks) .compile .lastOrError - .assertEquals(Chunk.bytes(testResource.toArray.splitAt(testResource.size - 4)._2)) *> + .assertEquals(Chunk.array(testResource.toArray.splitAt(testResource.size - 4)._2)) *> routes.orNotFound(req).map(_.status).assertEquals(Status.PartialContent) } @@ -241,7 +238,7 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { .flatMap(_.body.chunks) .compile .lastOrError - .assertEquals(Chunk.bytes(testResource.toArray.slice(2, 4 + 1))) *> + .assertEquals(Chunk.array(testResource.toArray.slice(2, 4 + 1))) *> routes.orNotFound(req).map(_.status).assertEquals(Status.PartialContent) // the end number is inclusive in the Range header } @@ -270,13 +267,13 @@ class FileServiceSuite extends Http4sSuite with StaticContentShared { } test("handle a relative system path") { - val s = fileService(FileService.Config[IO](".", blocker = testBlocker)) + val s = fileService(FileService.Config[IO](".")) IO(Paths.get(".").resolve("build.sbt").toFile.exists()).assert *> s.orNotFound(Request[IO](uri = uri"/build.sbt")).map(_.status).assertEquals(Status.Ok) } test("404 if system path is not found") { - val s = fileService(FileService.Config[IO]("./does-not-exist", blocker = testBlocker)) + val s = fileService(FileService.Config[IO]("./does-not-exist")) s.orNotFound(Request[IO](uri = uri"/build.sbt")).map(_.status).assertEquals(Status.NotFound) } } diff --git a/server/src/test/scala/org/http4s/server/staticcontent/ResourceServiceSuite.scala b/server/src/test/scala/org/http4s/server/staticcontent/ResourceServiceSuite.scala index b0facffdae9..4a238071b17 100644 --- a/server/src/test/scala/org/http4s/server/staticcontent/ResourceServiceSuite.scala +++ b/server/src/test/scala/org/http4s/server/staticcontent/ResourceServiceSuite.scala @@ -36,7 +36,7 @@ class ResourceServiceSuite extends Http4sSuite with StaticContentShared { // ResourceService.Config[IO]("", blocker = testBlocker) // val defaultBase = getClass.getResource("/").getPath.toString // val routes = resourceService(config) - val builder = resourceServiceBuilder[IO]("", testBlocker) + val builder = resourceServiceBuilder[IO]("") def routes: HttpRoutes[IO] = builder.toRoutes val defaultBase = getClass.getResource("/").getPath.toString diff --git a/server/src/test/scala/org/http4s/server/staticcontent/StaticContentShared.scala b/server/src/test/scala/org/http4s/server/staticcontent/StaticContentShared.scala index 3bfd70da4a1..d2461281da1 100644 --- a/server/src/test/scala/org/http4s/server/staticcontent/StaticContentShared.scala +++ b/server/src/test/scala/org/http4s/server/staticcontent/StaticContentShared.scala @@ -35,7 +35,7 @@ private[staticcontent] trait StaticContentShared { this: Http4sSuite => .mkString .getBytes(StandardCharsets.UTF_8) - Chunk.bytes(bytes) + Chunk.array(bytes) } lazy val testResourceGzipped: Chunk[Byte] = { @@ -43,7 +43,7 @@ private[staticcontent] trait StaticContentShared { this: Http4sSuite => require(url != null, "Couldn't acquire resource!") val bytes = Files.readAllBytes(Paths.get(url.toURI)) - Chunk.bytes(bytes) + Chunk.array(bytes) } lazy val testWebjarResource: Chunk[Byte] = { @@ -51,7 +51,7 @@ private[staticcontent] trait StaticContentShared { this: Http4sSuite => getClass.getResourceAsStream("/META-INF/resources/webjars/test-lib/1.0.0/testresource.txt") require(s != null, "Couldn't acquire resource!") - Chunk.bytes( + Chunk.array( scala.io.Source .fromInputStream(s) .mkString @@ -64,7 +64,7 @@ private[staticcontent] trait StaticContentShared { this: Http4sSuite => require(url != null, "Couldn't acquire resource!") val bytes = Files.readAllBytes(Paths.get(url.toURI)) - Chunk.bytes(bytes) + Chunk.array(bytes) } lazy val testWebjarSubResource: Chunk[Byte] = { @@ -72,7 +72,7 @@ private[staticcontent] trait StaticContentShared { this: Http4sSuite => "/META-INF/resources/webjars/test-lib/1.0.0/sub/testresource.txt") require(s != null, "Couldn't acquire resource!") - Chunk.bytes( + Chunk.array( scala.io.Source .fromInputStream(s) .mkString @@ -83,7 +83,7 @@ private[staticcontent] trait StaticContentShared { this: Http4sSuite => req: Request[IO], routes: HttpRoutes[IO] = routes): IO[(IO[Chunk[Byte]], Response[IO])] = routes.orNotFound(req).map { resp => - (resp.body.compile.to(Array).map(Chunk.bytes), resp) + (resp.body.compile.to(Array).map(Chunk.array[Byte]), resp) } } diff --git a/server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceFilterSuite.scala b/server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceFilterSuite.scala index e643067f7b2..f7a7cf81d87 100644 --- a/server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceFilterSuite.scala +++ b/server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceFilterSuite.scala @@ -23,10 +23,9 @@ import org.http4s.syntax.all._ class WebjarServiceFilterSuite extends Http4sSuite with StaticContentShared { def routes: HttpRoutes[IO] = - webjarServiceBuilder[IO](testBlocker) + webjarServiceBuilder[IO] .withWebjarAssetFilter(webjar => webjar.library == "test-lib" && webjar.version == "1.0.0" && webjar.asset == "testresource.txt") - .withBlocker(testBlocker) .toRoutes test("Return a 200 Ok file") { diff --git a/server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceSuite.scala b/server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceSuite.scala index 1f3c7ccf713..7dfd47ae366 100644 --- a/server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceSuite.scala +++ b/server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceSuite.scala @@ -28,15 +28,15 @@ import org.http4s.syntax.all._ class WebjarServiceSuite extends Http4sSuite with StaticContentShared { def routes: HttpRoutes[IO] = - webjarServiceBuilder[IO](testBlocker).toRoutes + webjarServiceBuilder[IO].toRoutes def routes(classLoader: ClassLoader): HttpRoutes[IO] = - webjarServiceBuilder[IO](testBlocker) + webjarServiceBuilder[IO] .withClassLoader(Some(classLoader)) .toRoutes def routes(preferGzipped: Boolean): HttpRoutes[IO] = - webjarServiceBuilder[IO](testBlocker) + webjarServiceBuilder[IO] .withPreferGzipped(preferGzipped) .toRoutes diff --git a/servlet/src/main/scala/org/http4s/servlet/AsyncHttp4sServlet.scala b/servlet/src/main/scala/org/http4s/servlet/AsyncHttp4sServlet.scala index e9d8f3e9d7d..670daa8c81f 100644 --- a/servlet/src/main/scala/org/http4s/servlet/AsyncHttp4sServlet.scala +++ b/servlet/src/main/scala/org/http4s/servlet/AsyncHttp4sServlet.scala @@ -17,21 +17,23 @@ package org.http4s package servlet -import cats.effect._ -import cats.effect.concurrent.Deferred -import cats.syntax.all._ import javax.servlet._ -import javax.servlet.http.{HttpServletRequest, HttpServletResponse} -import org.http4s.internal.loggingAsyncCallback -import org.http4s.server._ +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + import scala.concurrent.duration.Duration +import cats.effect.kernel.{Async, Deferred} +import cats.effect.std.Dispatcher +import cats.syntax.all._ +import org.http4s.server._ class AsyncHttp4sServlet[F[_]]( service: HttpApp[F], asyncTimeout: Duration = Duration.Inf, servletIo: ServletIo[F], - serviceErrorHandler: ServiceErrorHandler[F])(implicit F: ConcurrentEffect[F]) - extends Http4sServlet[F](service, servletIo) { + serviceErrorHandler: ServiceErrorHandler[F], + dispatcher: Dispatcher[F])(implicit F: Async[F]) + extends Http4sServlet[F](service, servletIo, dispatcher) { private val asyncTimeoutMillis = if (asyncTimeout.isFinite) asyncTimeout.toMillis else -1 // -1 == Inf @@ -42,7 +44,7 @@ class AsyncHttp4sServlet[F[_]]( private def logServletIo(): Unit = logger.info(servletIo match { - case BlockingServletIo(chunkSize, _) => + case BlockingServletIo(chunkSize) => s"Using blocking servlet I/O with chunk size $chunkSize" case NonBlockingServletIo(chunkSize) => s"Using non-blocking servlet I/O with chunk size $chunkSize" @@ -56,17 +58,18 @@ class AsyncHttp4sServlet[F[_]]( ctx.setTimeout(asyncTimeoutMillis) // Must be done on the container thread for Tomcat's sake when using async I/O. val bodyWriter = servletIo.initWriter(servletResponse) - F.runAsync( - toRequest(servletRequest).fold( - onParseFailure(_, servletResponse, bodyWriter), - handleRequest(ctx, _, bodyWriter) - )) { - case Right(()) => - IO(ctx.complete()) - case Left(t) => - IO(errorHandler(servletRequest, servletResponse)(t)) - }.unsafeRunSync() - } catch errorHandler(servletRequest, servletResponse) + val result = F + .attempt( + toRequest(servletRequest).fold( + onParseFailure(_, servletResponse, bodyWriter), + handleRequest(ctx, _, bodyWriter) + )) + .flatMap { + case Right(()) => F.delay(ctx.complete) + case Left(t) => errorHandler(servletRequest, servletResponse)(t) + } + dispatcher.unsafeRunAndForget(result) + } catch errorHandler(servletRequest, servletResponse).andThen(dispatcher.unsafeRunSync _) private def handleRequest( ctx: AsyncContext, @@ -76,12 +79,13 @@ class AsyncHttp4sServlet[F[_]]( // It is an error to add a listener to an async context that is // already completed, so we must take care to add the listener // before the response can complete. + val timeout = - F.asyncF[Response[F]](cb => gate.complete(ctx.addListener(new AsyncTimeoutHandler(cb)))) + F.async[Response[F]](cb => + gate.complete(ctx.addListener(new AsyncTimeoutHandler(cb))).as(Option.empty[F[Unit]])) val response = gate.get *> - Sync[F] - .defer(serviceFn(request)) + F.defer(serviceFn(request)) .recoverWith(serviceErrorHandler(request)) val servletResponse = ctx.getResponse.asInstanceOf[HttpServletResponse] F.race(timeout, response).flatMap(r => renderResponse(r.merge, servletResponse, bodyWriter)) @@ -89,12 +93,11 @@ class AsyncHttp4sServlet[F[_]]( private def errorHandler( servletRequest: ServletRequest, - servletResponse: HttpServletResponse): PartialFunction[Throwable, Unit] = { + servletResponse: HttpServletResponse): PartialFunction[Throwable, F[Unit]] = { case t: Throwable if servletResponse.isCommitted => - logger.error(t)("Error processing request after response was committed") + F.delay(logger.error(t)("Error processing request after response was committed")) case t: Throwable => - logger.error(t)("Error processing request") val response = Response[F](Status.InternalServerError) // We don't know what I/O mode we're in here, and we're not rendering a body // anyway, so we use a NullBodyWriter. @@ -103,7 +106,12 @@ class AsyncHttp4sServlet[F[_]]( if (servletRequest.isAsyncStarted) servletRequest.getAsyncContext.complete() ) - F.runAsync(f)(loggingAsyncCallback(logger)).unsafeRunSync() + F.delay(logger.error(t)("Error processing request")) *> F + .attempt(f) + .flatMap { + case Right(()) => F.unit + case Left(e) => F.delay(logger.error(e)("Error in error handler")) + } } private class AsyncTimeoutHandler(cb: Callback[Response[F]]) extends AbstractAsyncListener { @@ -116,13 +124,15 @@ class AsyncHttp4sServlet[F[_]]( } object AsyncHttp4sServlet { - def apply[F[_]: ConcurrentEffect]( + def apply[F[_]: Async]( service: HttpApp[F], - asyncTimeout: Duration = Duration.Inf): AsyncHttp4sServlet[F] = + asyncTimeout: Duration = Duration.Inf, + dispatcher: Dispatcher[F]): AsyncHttp4sServlet[F] = new AsyncHttp4sServlet[F]( service, asyncTimeout, NonBlockingServletIo[F](DefaultChunkSize), - DefaultServiceErrorHandler + DefaultServiceErrorHandler, + dispatcher ) } diff --git a/servlet/src/main/scala/org/http4s/servlet/BlockingHttp4sServlet.scala b/servlet/src/main/scala/org/http4s/servlet/BlockingHttp4sServlet.scala index 0525a877140..79cc79c09e9 100644 --- a/servlet/src/main/scala/org/http4s/servlet/BlockingHttp4sServlet.scala +++ b/servlet/src/main/scala/org/http4s/servlet/BlockingHttp4sServlet.scala @@ -17,40 +17,42 @@ package org.http4s package servlet -import cats.effect._ -import cats.effect.implicits._ +import cats.effect.kernel.Async import cats.syntax.all._ import javax.servlet.http.{HttpServletRequest, HttpServletResponse} import org.http4s.server._ +import cats.effect.std.Dispatcher class BlockingHttp4sServlet[F[_]]( service: HttpApp[F], - servletIo: BlockingServletIo[F], - serviceErrorHandler: ServiceErrorHandler[F])(implicit F: Effect[F]) - extends Http4sServlet[F](service, servletIo) { + servletIo: ServletIo[F], + serviceErrorHandler: ServiceErrorHandler[F], + dispatcher: Dispatcher[F])(implicit F: Async[F]) + extends Http4sServlet[F](service, servletIo, dispatcher) { override def service( servletRequest: HttpServletRequest, - servletResponse: HttpServletResponse): Unit = - F.defer { - val bodyWriter = servletIo.initWriter(servletResponse) + servletResponse: HttpServletResponse): Unit = { + val result = F + .defer { + val bodyWriter = servletIo.initWriter(servletResponse) - val render = toRequest(servletRequest).fold( - onParseFailure(_, servletResponse, bodyWriter), - handleRequest(_, servletResponse, bodyWriter) - ) + val render = toRequest(servletRequest).fold( + onParseFailure(_, servletResponse, bodyWriter), + handleRequest(_, servletResponse, bodyWriter) + ) - render - }.handleErrorWith(errorHandler(servletResponse)) - .toIO - .unsafeRunSync() + render + } + .handleErrorWith(errorHandler(servletResponse)) + dispatcher.unsafeRunSync(result) + } private def handleRequest( request: Request[F], servletResponse: HttpServletResponse, bodyWriter: BodyWriter[F]): F[Unit] = // Note: We're catching silly user errors in the lift => flatten. - Sync[F] - .defer(serviceFn(request)) + F.defer(serviceFn(request)) .recoverWith(serviceErrorHandler(request)) .flatMap(renderResponse(_, servletResponse, bodyWriter)) @@ -70,12 +72,14 @@ class BlockingHttp4sServlet[F[_]]( } object BlockingHttp4sServlet { - def apply[F[_]: Effect: ContextShift]( + def apply[F[_]: Async]( service: HttpApp[F], - blocker: Blocker): BlockingHttp4sServlet[F] = + servletIo: ServletIo[F], + dispatcher: Dispatcher[F]): BlockingHttp4sServlet[F] = new BlockingHttp4sServlet[F]( service, - BlockingServletIo(DefaultChunkSize, blocker), - DefaultServiceErrorHandler + servletIo, + DefaultServiceErrorHandler, + dispatcher ) } diff --git a/servlet/src/main/scala/org/http4s/servlet/Http4sServlet.scala b/servlet/src/main/scala/org/http4s/servlet/Http4sServlet.scala index b1fb82b311d..ce1b913add7 100644 --- a/servlet/src/main/scala/org/http4s/servlet/Http4sServlet.scala +++ b/servlet/src/main/scala/org/http4s/servlet/Http4sServlet.scala @@ -16,7 +16,8 @@ package org.http4s.servlet -import cats.effect._ +import cats.effect.kernel.Async +import cats.effect.std.Dispatcher import cats.syntax.all._ import com.comcast.ip4s.{IpAddress, Port, SocketAddress} import java.security.cert.X509Certificate @@ -30,8 +31,10 @@ import org.log4s.getLogger import org.typelevel.ci._ import org.typelevel.vault._ -abstract class Http4sServlet[F[_]](service: HttpApp[F], servletIo: ServletIo[F])(implicit - F: Effect[F]) +abstract class Http4sServlet[F[_]]( + service: HttpApp[F], + servletIo: ServletIo[F], + dispatcher: Dispatcher[F])(implicit F: Async[F]) extends HttpServlet { protected val logger = getLogger @@ -42,7 +45,10 @@ abstract class Http4sServlet[F[_]](service: HttpApp[F], servletIo: ServletIo[F]) private[this] var serverSoftware: ServerSoftware = _ object ServletRequestKeys { - val HttpSession: Key[Option[HttpSession]] = Key.newKey[IO, Option[HttpSession]].unsafeRunSync() + val HttpSession: Key[Option[HttpSession]] = { + val result = Key.newKey[F, Option[HttpSession]] + dispatcher.unsafeRunSync(result) + } } override def init(config: ServletConfig): Unit = { @@ -77,7 +83,7 @@ abstract class Http4sServlet[F[_]](service: HttpApp[F], servletIo: ServletIo[F]) .flatMap { case Right(()) => bodyWriter(response) case Left(t) => - response.body.drain.compile.drain.handleError { case t2 => + response.body.drain.compile.drain.handleError { t2 => logger.error(t2)("Error draining body") } *> F.raiseError(t) } diff --git a/servlet/src/main/scala/org/http4s/servlet/ServletContainer.scala b/servlet/src/main/scala/org/http4s/servlet/ServletContainer.scala index 532c2d90644..c17790e62b7 100644 --- a/servlet/src/main/scala/org/http4s/servlet/ServletContainer.scala +++ b/servlet/src/main/scala/org/http4s/servlet/ServletContainer.scala @@ -60,7 +60,7 @@ abstract class ServletContainer[F[_]] extends ServerBuilder[F] { } object ServletContainer { - def DefaultServletIo[F[_]: Effect]: ServletIo[F] = NonBlockingServletIo[F](DefaultChunkSize) + def DefaultServletIo[F[_]: Async]: ServletIo[F] = NonBlockingServletIo[F](DefaultChunkSize) /** Trims an optional trailing slash and then appends "/\u002b'. Translates an argument to * mountService into a standard servlet prefix mapping. diff --git a/servlet/src/main/scala/org/http4s/servlet/ServletIo.scala b/servlet/src/main/scala/org/http4s/servlet/ServletIo.scala index 24ab1f914aa..ec43eefe137 100644 --- a/servlet/src/main/scala/org/http4s/servlet/ServletIo.scala +++ b/servlet/src/main/scala/org/http4s/servlet/ServletIo.scala @@ -24,7 +24,6 @@ import java.util.concurrent.atomic.AtomicReference import javax.servlet.{ReadListener, WriteListener} import javax.servlet.http.{HttpServletRequest, HttpServletResponse} import org.http4s.internal.bug -import org.http4s.internal.Trampoline import org.log4s.getLogger import scala.annotation.tailrec @@ -44,10 +43,9 @@ sealed abstract class ServletIo[F[_]: Async] { * This is more CPU efficient per request than [[NonBlockingServletIo]], but is likely to * require a larger request thread pool for the same load. */ -final case class BlockingServletIo[F[_]: Effect: ContextShift](chunkSize: Int, blocker: Blocker) - extends ServletIo[F] { +final case class BlockingServletIo[F[_]: Async](chunkSize: Int) extends ServletIo[F] { override protected[servlet] def reader(servletRequest: HttpServletRequest): EntityBody[F] = - io.readInputStream[F](F.pure(servletRequest.getInputStream), chunkSize, blocker) + io.readInputStream[F](F.pure(servletRequest.getInputStream), chunkSize) override protected[servlet] def initWriter( servletResponse: HttpServletResponse): BodyWriter[F] = { (response: Response[F]) => @@ -55,9 +53,9 @@ final case class BlockingServletIo[F[_]: Effect: ContextShift](chunkSize: Int, b val flush = response.isChunked response.body.chunks .evalTap { chunk => - blocker.delay[F, Unit] { + Async[F].delay { // Avoids copying for specialized chunks - val byteChunk = chunk.toBytes + val byteChunk = chunk.toArraySlice out.write(byteChunk.values, byteChunk.offset, byteChunk.length) if (flush) servletResponse.flushBuffer() @@ -75,7 +73,7 @@ final case class BlockingServletIo[F[_]: Effect: ContextShift](chunkSize: Int, b * under high load up through at least Tomcat 8.0.15. These appear to be harmless, but are * operationally annoying. */ -final case class NonBlockingServletIo[F[_]: Effect](chunkSize: Int) extends ServletIo[F] { +final case class NonBlockingServletIo[F[_]: Async](chunkSize: Int) extends ServletIo[F] { private[this] val logger = getLogger private[this] def rightSome[A](a: A) = Right(Some(a)) @@ -98,14 +96,14 @@ final case class NonBlockingServletIo[F[_]: Effect](chunkSize: Int) extends Serv val buf = new Array[Byte](chunkSize) val len = in.read(buf) - if (len == chunkSize) cb(rightSome(Chunk.bytes(buf))) + if (len == chunkSize) cb(rightSome(Chunk.array(buf))) else if (len < 0) { state.compareAndSet(Ready, Complete) // will not overwrite an `Errored` state cb(rightNone) } else if (len == 0) { logger.warn("Encountered a read of length 0") cb(rightSome(Chunk.empty)) - } else cb(rightSome(Chunk.bytes(buf, 0, len))) + } else cb(rightSome(Chunk.array(buf, 0, len))) } if (in.isFinished) Stream.empty @@ -113,71 +111,69 @@ final case class NonBlockingServletIo[F[_]: Effect](chunkSize: Int) extends Serv // This effect sets the callback and waits for the first bytes to read val registerRead = // Shift execution to a different EC - Async.shift(Trampoline) *> - F.async[Option[Chunk[Byte]]] { cb => - if (!state.compareAndSet(Init, Blocked(cb))) - cb(Left(bug("Shouldn't have gotten here: I should be the first to set a state"))) - else - in.setReadListener( - new ReadListener { - override def onDataAvailable(): Unit = - state.getAndSet(Ready) match { - case Blocked(cb) => read(cb) - case _ => () - } + F.async_[Option[Chunk[Byte]]] { cb => + if (!state.compareAndSet(Init, Blocked(cb))) + cb(Left(bug("Shouldn't have gotten here: I should be the first to set a state"))) + else + in.setReadListener( + new ReadListener { + override def onDataAvailable(): Unit = + state.getAndSet(Ready) match { + case Blocked(cb) => read(cb) + case _ => () + } - override def onError(t: Throwable): Unit = - state.getAndSet(Errored(t)) match { - case Blocked(cb) => cb(Left(t)) - case _ => () - } + override def onError(t: Throwable): Unit = + state.getAndSet(Errored(t)) match { + case Blocked(cb) => cb(Left(t)) + case _ => () + } - override def onAllDataRead(): Unit = - state.getAndSet(Complete) match { - case Blocked(cb) => cb(rightNone) - case _ => () - } - } - ) - } + override def onAllDataRead(): Unit = + state.getAndSet(Complete) match { + case Blocked(cb) => cb(rightNone) + case _ => () + } + } + ) + } val readStream = Stream.eval(registerRead) ++ Stream .repeatEval( // perform the initial set then transition into normal read mode // Shift execution to a different EC - Async.shift(Trampoline) *> - F.async[Option[Chunk[Byte]]] { cb => - @tailrec - def go(): Unit = - state.get match { - case Ready if in.isReady => read(cb) + F.async_[Option[Chunk[Byte]]] { cb => + @tailrec + def go(): Unit = + state.get match { + case Ready if in.isReady => read(cb) - case Ready => // wasn't ready so set the callback and double check that we're still not ready - val blocked = Blocked(cb) - if (state.compareAndSet(Ready, blocked)) - if (in.isReady && state.compareAndSet(blocked, Ready)) - read(cb) // data became available while we were setting up the callbacks - else { - /* NOOP: our callback is either still needed or has been handled */ - } - else go() // Our state transitioned so try again. + case Ready => // wasn't ready so set the callback and double check that we're still not ready + val blocked = Blocked(cb) + if (state.compareAndSet(Ready, blocked)) + if (in.isReady && state.compareAndSet(blocked, Ready)) + read(cb) // data became available while we were setting up the callbacks + else { + /* NOOP: our callback is either still needed or has been handled */ + } + else go() // Our state transitioned so try again. - case Complete => cb(rightNone) + case Complete => cb(rightNone) - case Errored(t) => cb(Left(t)) + case Errored(t) => cb(Left(t)) - // This should never happen so throw a huge fit if it does. - case Blocked(c1) => - val t = bug("Two callbacks found in read state") - cb(Left(t)) - c1(Left(t)) - logger.error(t)("This should never happen. Please report.") - throw t + // This should never happen so throw a huge fit if it does. + case Blocked(c1) => + val t = bug("Two callbacks found in read state") + cb(Left(t)) + c1(Left(t)) + logger.error(t)("This should never happen. Please report.") + throw t - case Init => - cb(Left(bug("Should have left Init state by now"))) - } - go() - }) + case Init => + cb(Left(bug("Should have left Init state by now"))) + } + go() + }) readStream.unNoneTerminate.flatMap(Stream.chunk) } } @@ -233,15 +229,14 @@ final case class NonBlockingServletIo[F[_]: Effect](chunkSize: Int) extends Serv */ out.setWriteListener(listener) - val awaitLastWrite = Stream.eval_ { + val awaitLastWrite = Stream.exec { // Shift execution to a different EC - Async.shift(Trampoline) *> - F.async[Unit] { cb => - state.getAndSet(AwaitingLastWrite(cb)) match { - case Ready if out.isReady => cb(Right(())) - case _ => () - } + F.async_[Unit] { cb => + state.getAndSet(AwaitingLastWrite(cb)) match { + case Ready if out.isReady => cb(Right(())) + case _ => () } + } } { (response: Response[F]) => @@ -250,20 +245,19 @@ final case class NonBlockingServletIo[F[_]: Effect](chunkSize: Int) extends Serv response.body.chunks .evalMap { chunk => // Shift execution to a different EC - Async.shift(Trampoline) *> - F.async[Chunk[Byte] => Unit] { cb => - val blocked = Blocked(cb) - state.getAndSet(blocked) match { - case Ready if out.isReady => - if (state.compareAndSet(blocked, Ready)) - cb(writeChunk) - case e @ Errored(t) => - if (state.compareAndSet(blocked, e)) - cb(Left(t)) - case _ => - () - } - }.map(_(chunk)) + F.async_[Chunk[Byte] => Unit] { cb => + val blocked = Blocked(cb) + state.getAndSet(blocked) match { + case Ready if out.isReady => + if (state.compareAndSet(blocked, Ready)) + cb(writeChunk) + case e @ Errored(t) => + if (state.compareAndSet(blocked, e)) + cb(Left(t)) + case _ => + () + } + }.map(_(chunk)) } .append(awaitLastWrite) .compile diff --git a/servlet/src/main/scala/org/http4s/servlet/syntax/ServletContextSyntax.scala b/servlet/src/main/scala/org/http4s/servlet/syntax/ServletContextSyntax.scala index 5b99da9fb56..89603baab47 100644 --- a/servlet/src/main/scala/org/http4s/servlet/syntax/ServletContextSyntax.scala +++ b/servlet/src/main/scala/org/http4s/servlet/syntax/ServletContextSyntax.scala @@ -23,6 +23,7 @@ import javax.servlet.{ServletContext, ServletRegistration} import org.http4s.server.DefaultServiceErrorHandler import org.http4s.server.defaults import org.http4s.syntax.all._ +import cats.effect.std.Dispatcher trait ServletContextSyntax { implicit def ToServletContextOps(self: ServletContext): ServletContextOps = @@ -35,21 +36,24 @@ final class ServletContextOps private[syntax] (val self: ServletContext) extends * * Assumes non-blocking servlet IO is available, and thus requires at least Servlet 3.1. */ - def mountService[F[_]: ConcurrentEffect]( + def mountService[F[_]: Async]( name: String, service: HttpRoutes[F], - mapping: String = "/*"): ServletRegistration.Dynamic = - mountHttpApp(name, service.orNotFound, mapping) + mapping: String = "/*", + dispatcher: Dispatcher[F]): ServletRegistration.Dynamic = + mountHttpApp(name, service.orNotFound, mapping, dispatcher) - def mountHttpApp[F[_]: ConcurrentEffect]( + def mountHttpApp[F[_]: Async]( name: String, service: HttpApp[F], - mapping: String = "/*"): ServletRegistration.Dynamic = { + mapping: String = "/*", + dispatcher: Dispatcher[F]): ServletRegistration.Dynamic = { val servlet = new AsyncHttp4sServlet( service = service, asyncTimeout = defaults.ResponseTimeout, servletIo = NonBlockingServletIo(DefaultChunkSize), - serviceErrorHandler = DefaultServiceErrorHandler[F] + serviceErrorHandler = DefaultServiceErrorHandler[F], + dispatcher ) val reg = self.addServlet(name, servlet) reg.setLoadOnStartup(1) diff --git a/servlet/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala b/servlet/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala index 9c0178c730d..da2efe70b99 100644 --- a/servlet/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala +++ b/servlet/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala @@ -18,7 +18,8 @@ package org.http4s package servlet import cats.syntax.all._ -import cats.effect.{IO, Resource, Timer} +import cats.effect.{IO, Resource} +import cats.effect.std.Dispatcher import java.net.URL import org.eclipse.jetty.server.HttpConfiguration import org.eclipse.jetty.server.HttpConnectionFactory @@ -39,17 +40,16 @@ class AsyncHttp4sServletSuite extends Http4sSuite { case req @ POST -> Root / "echo" => Ok(req.body) case GET -> Root / "shifted" => - IO.shift(munitExecutionContext) *> - // Wait for a bit to make sure we lose the race - Timer[IO].sleep(50.millis) *> - Ok("shifted") + // Wait for a bit to make sure we lose the race + (IO.sleep(50.millis) *> + Ok("shifted")).evalOn(munitExecutionContext) } .orNotFound val servletServer = ResourceFixture[Int](serverPortR) def get(serverPort: Int, path: String): IO[String] = - testBlocker.delay[IO, String]( + IO.blocking[String]( Source .fromURL(new URL(s"http://127.0.0.1:$serverPort/$path")) .getLines() @@ -79,15 +79,13 @@ class AsyncHttp4sServletSuite extends Http4sSuite { .execute() .toCompletableFuture() }.flatMap { cf => - IO.cancelable[Response] { cb => - val stage = cf.handle[Unit] { - case (response, null) => cb(Right(response)) - case (_, t) => cb(Left(t)) - } - - IO { - stage.cancel(false) - () + IO.async[Response] { cb => + IO.delay { + val stage = cf.handle[Unit] { + case (response, null) => cb(Right(response)) + case (_, t) => cb(Left(t)) + } + Some(IO.delay(stage.cancel(false)).void) } } }.flatMap { response => @@ -102,28 +100,29 @@ class AsyncHttp4sServletSuite extends Http4sSuite { get(server, "shifted").assertEquals("shifted") } - lazy val servlet = new AsyncHttp4sServlet[IO]( - service = service, - servletIo = NonBlockingServletIo[IO](4096), - serviceErrorHandler = DefaultServiceErrorHandler[IO] - ) + lazy val serverPortR = for { + dispatcher <- Dispatcher[IO] + server <- Resource.make(IO(new EclipseServer))(server => IO(server.stop())) + servlet = new AsyncHttp4sServlet[IO]( + service = service, + dispatcher = dispatcher, + servletIo = NonBlockingServletIo[IO](4096), + serviceErrorHandler = DefaultServiceErrorHandler[IO] + ) + port <- Resource.eval(IO { + val connector = + new ServerConnector(server, new HttpConnectionFactory(new HttpConfiguration())) - lazy val serverPortR = Resource - .make(IO(new EclipseServer))(server => IO(server.stop())) - .evalMap { server => - IO { - val connector = - new ServerConnector(server, new HttpConnectionFactory(new HttpConfiguration())) + val context = new ServletContextHandler + context.addServlet(new ServletHolder(servlet), "/*") - val context = new ServletContextHandler - context.addServlet(new ServletHolder(servlet), "/*") + server.addConnector(connector) + server.setHandler(context) - server.addConnector(connector) - server.setHandler(context) + server.start() - server.start() + connector.getLocalPort + }) + } yield port - connector.getLocalPort - } - } } diff --git a/servlet/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala b/servlet/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala index d2b47b9fbeb..baadb9333ff 100644 --- a/servlet/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala +++ b/servlet/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala @@ -18,7 +18,10 @@ package org.http4s package servlet import cats.syntax.all._ -import cats.effect.{IO, Resource, Timer} +import cats.effect.{IO, Resource} +import cats.effect.kernel.Temporal +import cats.effect.std.Dispatcher + import java.net.{HttpURLConnection, URL} import java.nio.charset.StandardCharsets import org.eclipse.jetty.server.HttpConfiguration @@ -33,6 +36,7 @@ import scala.io.Source import scala.concurrent.duration._ class BlockingHttp4sServletSuite extends Http4sSuite { + lazy val service = HttpRoutes .of[IO] { case GET -> Root / "simple" => @@ -40,24 +44,23 @@ class BlockingHttp4sServletSuite extends Http4sSuite { case req @ POST -> Root / "echo" => Ok(req.body) case GET -> Root / "shifted" => - IO.shift(munitExecutionContext) *> - // Wait for a bit to make sure we lose the race - Timer[IO].sleep(50.millis) *> - Ok("shifted") + // Wait for a bit to make sure we lose the race + Temporal[IO].sleep(50.milli) *> Ok("shifted") } .orNotFound - val servletServer = ResourceFixture[Int](serverPortR) + val servletServer = + ResourceFixture(Dispatcher[IO].flatMap(d => serverPortR(d))) def get(serverPort: Int, path: String): IO[String] = - testBlocker.delay[IO, String]( + IO( Source .fromURL(new URL(s"http://127.0.0.1:$serverPort/$path")) .getLines() .mkString) def post(serverPort: Int, path: String, body: String): IO[String] = - testBlocker.delay[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) @@ -80,28 +83,34 @@ class BlockingHttp4sServletSuite extends Http4sSuite { get(server, "shifted").assertEquals("shifted") } - lazy val servlet = new BlockingHttp4sServlet[IO]( - service = service, - servletIo = org.http4s.servlet.BlockingServletIo(4096, testBlocker), - serviceErrorHandler = DefaultServiceErrorHandler - ) + val servlet: Dispatcher[IO] => Http4sServlet[IO] = { dispatcher => + new BlockingHttp4sServlet[IO]( + service = service, + servletIo = org.http4s.servlet.BlockingServletIo(4096), + serviceErrorHandler = DefaultServiceErrorHandler, + dispatcher + ) + } - lazy val serverPortR = Resource - .make(IO(new EclipseServer))(server => IO(server.stop())) - .evalMap { server => - IO { - val connector = - new ServerConnector(server, new HttpConnectionFactory(new HttpConfiguration())) + lazy val serverPortR: Dispatcher[IO] => Resource[IO, Int] = { dispatcher => + Resource + .make(IO(new EclipseServer))(server => IO(server.stop())) + .evalMap { server => + IO { + val connector = + new ServerConnector(server, new HttpConnectionFactory(new HttpConfiguration())) - val context = new ServletContextHandler - context.addServlet(new ServletHolder(servlet), "/*") + val context = new ServletContextHandler + context.addServlet(new ServletHolder(servlet(dispatcher)), "/*") - server.addConnector(connector) - server.setHandler(context) + server.addConnector(connector) + server.setHandler(context) - server.start() + server.start() - connector.getLocalPort + connector.getLocalPort + } } - } + } + } diff --git a/specs2/src/test/scala/org/http4s/Http4sSpec.scala b/specs2/src/test/scala/org/http4s/Http4sSpec.scala new file mode 100644 index 00000000000..4c846f1a83c --- /dev/null +++ b/specs2/src/test/scala/org/http4s/Http4sSpec.scala @@ -0,0 +1,136 @@ +/* + * Copyright 2013-2020 http4s.org + * + * SPDX-License-Identifier: Apache-2.0 + * + * Based on https://github.com/typelevel/scalaz-specs2/src/main/scala/Spec.scala + * Copyright (C) 2013 Lars Hupel + * See licenses/LICENSE_scalaz-specs2 + */ + +package org.http4s + +import cats.effect.{IO, Resource} +import cats.syntax.all._ +import fs2._ +import fs2.text._ + +import java.util.concurrent.{ScheduledExecutorService, ScheduledThreadPoolExecutor, TimeUnit} +import org.http4s.internal.threads.{newBlockingPool, newDaemonPool, threadFactory} +import org.http4s.laws.discipline.ArbitraryInstances +import org.scalacheck._ +import org.scalacheck.util.{FreqMap, Pretty} +import org.specs2.ScalaCheck +import org.specs2.execute.{Result, Skipped} +import org.specs2.matcher._ +import org.specs2.mutable.Specification +import org.specs2.scalacheck.Parameters +import org.specs2.specification.core.Fragments +import org.specs2.specification.create.{DefaultFragmentFactory} +import org.specs2.specification.dsl.FragmentsDsl +import org.typelevel.discipline.specs2.mutable.Discipline + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration._ +import cats.effect.unsafe.IORuntime +import cats.effect.unsafe.Scheduler + +/** Common stack for http4s' own specs. + * + * Not published in testing's main, because it doesn't depend on specs2. + */ +trait Http4sSpec + extends Specification + with ScalaCheck + with AnyMatchers + with OptionMatchers + with syntax.AllSyntax + with ArbitraryInstances + with FragmentsDsl + with Discipline { + + implicit val testIORuntime: IORuntime = Http4sSpec.TestIORuntime + + protected val timeout: FiniteDuration = 10.seconds + + implicit val params: Parameters = Parameters(maxSize = 20) + + implicit class ParseResultSyntax[A](self: ParseResult[A]) { + def yolo: A = self.valueOr(e => sys.error(e.toString)) + } + + def writeToString[A](a: A)(implicit W: EntityEncoder[IO, A]): String = + Stream + .emit(W.toEntity(a)) + .covary[IO] + .flatMap(_.body) + .through(utf8.decode) + .foldMonoid + .compile + .last + .map(_.getOrElse("")) + .unsafeRunSync() + + def checkAll(name: String, props: Properties)(implicit + p: Parameters, + f: FreqMap[Set[Any]] => Pretty): Fragments = { + addFragment(DefaultFragmentFactory.text(s"$name ${props.name} must satisfy")) + addBreak + addFragments(Fragments.foreach(props.properties.toList) { case (name, prop) => + Fragments(name in check(prop, p, f)) + }) + } + + def checkAll( + props: Properties)(implicit p: Parameters, f: FreqMap[Set[Any]] => Pretty): Fragments = { + addFragment(DefaultFragmentFactory.text(s"${props.name} must satisfy")) + addFragments(Fragments.foreach(props.properties.toList) { case (name, prop) => + Fragments(name in check(prop, p, f)) + }) + } + + def beStatus(status: Status): Matcher[Response[IO]] = { (resp: Response[IO]) => + (resp.status == status) -> s" doesn't have status $status" + } + + def withResource[A](r: Resource[IO, A])(fs: A => Fragments): Fragments = + r.allocated + .map { case (r, release) => fs(r).append(step(release.unsafeRunTimed(timeout))) } + .unsafeRunTimed(timeout) + .getOrElse(throw new Exception(s"no result after $timeout")) + + /** These tests are flaky on Travis. Use sparingly and with great shame. */ + def skipOnCi(f: => Result): Result = + if (sys.env.get("CI").isDefined) Skipped("Flakier than it's worth on CI") + else f +} + +object Http4sSpec { + val TestExecutionContext: ExecutionContext = + ExecutionContext.fromExecutor(newDaemonPool("http4s-spec", timeout = true)) + + val TestScheduler: ScheduledExecutorService = { + val s = + new ScheduledThreadPoolExecutor(2, threadFactory(i => s"http4s-test-scheduler-$i", true)) + s.setKeepAliveTime(10L, TimeUnit.SECONDS) + s.allowCoreThreadTimeOut(true) + s + } + + val TestIORuntime: IORuntime = { + val blockingPool = newBlockingPool("http4s-spec-blocking") + val computePool = newDaemonPool("http4s-spec", timeout = true) + val scheduledExecutor = TestScheduler + IORuntime.apply( + ExecutionContext.fromExecutor(computePool), + ExecutionContext.fromExecutor(blockingPool), + Scheduler.fromScheduledExecutor(scheduledExecutor), + () => { + blockingPool.shutdown() + computePool.shutdown() + scheduledExecutor.shutdown() + } + ) + } + +} diff --git a/testing/src/test/scala/org/http4s/Http4sSuite.scala b/testing/src/test/scala/org/http4s/Http4sSuite.scala index abe6847a2df..833a59c5a4b 100644 --- a/testing/src/test/scala/org/http4s/Http4sSuite.scala +++ b/testing/src/test/scala/org/http4s/Http4sSuite.scala @@ -16,14 +16,15 @@ package org.http4s -import cats.effect._ +import cats.effect.{IO, Resource} +import cats.effect.unsafe.{IORuntime, IORuntimeConfig, Scheduler} import cats.syntax.all._ import fs2._ -import fs2.text.utf8Decode +import fs2.text.utf8 import java.util.concurrent.{ScheduledExecutorService, ScheduledThreadPoolExecutor, TimeUnit} +import munit._ import org.http4s.internal.threads.{newBlockingPool, newDaemonPool, threadFactory} import scala.concurrent.ExecutionContext -import munit._ /** Common stack for http4s' munit based tests */ @@ -33,6 +34,8 @@ trait Http4sSuite extends CatsEffectSuite with DisciplineSuite with munit.ScalaC override val munitExecutionContext = ExecutionContext.fromExecutor(newDaemonPool("http4s-munit", min = 1, timeout = true)) + override implicit val ioRuntime: IORuntime = Http4sSuite.TestIORuntime + private[this] val suiteFixtures = List.newBuilder[Fixture[_]] override def munitFixtures: Seq[Fixture[_]] = suiteFixtures.result() @@ -45,8 +48,6 @@ trait Http4sSuite extends CatsEffectSuite with DisciplineSuite with munit.ScalaC def resourceSuiteFixture[A](name: String, resource: Resource[IO, A]) = registerSuiteFixture( ResourceSuiteLocalFixture(name, resource)) - val testBlocker: Blocker = Http4sSuite.TestBlocker - // allow flaky tests on ci override def munitFlakyOK = sys.env.get("CI").isDefined @@ -59,7 +60,7 @@ trait Http4sSuite extends CatsEffectSuite with DisciplineSuite with munit.ScalaC .emit(W.toEntity(a)) .covary[IO] .flatMap(_.body) - .through(utf8Decode) + .through(utf8.decode) .foldMonoid .compile .last @@ -68,15 +69,9 @@ trait Http4sSuite extends CatsEffectSuite with DisciplineSuite with munit.ScalaC } object Http4sSuite { - val TestBlocker: Blocker = - Blocker.liftExecutorService(newBlockingPool("http4s-suite-blocking")) - val TestExecutionContext: ExecutionContext = ExecutionContext.fromExecutor(newDaemonPool("http4s-suite", timeout = true)) - val TestContextShift: ContextShift[IO] = - IO.contextShift(TestExecutionContext) - val TestScheduler: ScheduledExecutorService = { val s = new ScheduledThreadPoolExecutor(2, threadFactory(i => s"http4s-test-scheduler-$i", true)) @@ -85,6 +80,20 @@ object Http4sSuite { s } - val TestTimer: Timer[IO] = - IO.timer(TestExecutionContext, TestScheduler) + val TestIORuntime: IORuntime = { + val blockingPool = newBlockingPool("http4s-suite-blocking") + val computePool = newDaemonPool("http4s-suite", timeout = true) + val scheduledExecutor = TestScheduler + IORuntime.apply( + ExecutionContext.fromExecutor(computePool), + ExecutionContext.fromExecutor(blockingPool), + Scheduler.fromScheduledExecutor(scheduledExecutor), + () => { + blockingPool.shutdown() + computePool.shutdown() + scheduledExecutor.shutdown() + }, + IORuntimeConfig() + ) + } } diff --git a/testing/src/test/scala/org/http4s/testing/DispatcherIOFixture.scala b/testing/src/test/scala/org/http4s/testing/DispatcherIOFixture.scala new file mode 100644 index 00000000000..fa639cb0dc3 --- /dev/null +++ b/testing/src/test/scala/org/http4s/testing/DispatcherIOFixture.scala @@ -0,0 +1,27 @@ +/* + * Copyright 2016 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.testing + +import cats.effect.{IO, SyncIO} +import cats.effect.std.Dispatcher +import munit.CatsEffectSuite + +trait DispatcherIOFixture { this: CatsEffectSuite => + + def dispatcher: SyncIO[FunFixture[Dispatcher[IO]]] = ResourceFixture(Dispatcher[IO]) + +} diff --git a/testing/src/test/scala/org/http4s/testing/EqF.scala b/testing/src/test/scala/org/http4s/testing/EqF.scala new file mode 100644 index 00000000000..a52c772fe44 --- /dev/null +++ b/testing/src/test/scala/org/http4s/testing/EqF.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2016 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.testing + +import cats.Eq +import cats.effect.std.Dispatcher + +trait EqF { + implicit def eqF[A, F[_]](implicit eqA: Eq[A], dispatcher: Dispatcher[F]): Eq[F[A]] = + Eq.by[F[A], A](f => dispatcher.unsafeRunSync(f)) +} diff --git a/testing/src/test/scala/org/http4s/testing/fs2Arbitraries.scala b/testing/src/test/scala/org/http4s/testing/fs2Arbitraries.scala index 8a82a286608..1151cc7fa8f 100644 --- a/testing/src/test/scala/org/http4s/testing/fs2Arbitraries.scala +++ b/testing/src/test/scala/org/http4s/testing/fs2Arbitraries.scala @@ -23,5 +23,5 @@ import org.scalacheck.Arbitrary.arbitrary /** Arbitraries for fs2 types that aren't ours to publish. */ object fs2Arbitraries { implicit val http4sArbitraryForFs2ChunkOfBytes: Arbitrary[Chunk[Byte]] = - Arbitrary(Gen.containerOf[Array, Byte](arbitrary[Byte]).map(Chunk.bytes)) + Arbitrary(Gen.containerOf[Array, Byte](arbitrary[Byte]).map(Chunk.array[Byte])) } diff --git a/tests/src/test/scala/org/http4s/DecodeSpec.scala b/tests/src/test/scala/org/http4s/DecodeSpec.scala index 50399ff00a6..cf5a650ba71 100644 --- a/tests/src/test/scala/org/http4s/DecodeSpec.scala +++ b/tests/src/test/scala/org/http4s/DecodeSpec.scala @@ -17,65 +17,54 @@ package org.http4s import cats.syntax.all._ -import java.nio.charset.{Charset => JCharset} -import java.nio.charset.{ - CharacterCodingException, - MalformedInputException, - UnmappableCharacterException -} import fs2._ -import fs2.text.utf8Decode +import fs2.text.utf8 import org.http4s.internal.decode import org.http4s.laws.discipline.arbitrary._ -import java.nio.charset.StandardCharsets import org.scalacheck.Prop.{forAll, propBoolean} -class DecodeSpec extends Http4sSuite { - { - test("decode should be consistent with utf8Decode") { - forAll { (s: String, chunkSize: Int) => - (chunkSize > 0) ==> { - val source = Stream - .emits { - s.getBytes(StandardCharsets.UTF_8) - .grouped(chunkSize) - .map(_.toArray) - .map(Chunk.bytes) - .toSeq - } - .flatMap(Stream.chunk) - val utf8Decoded = utf8Decode(source).toList.combineAll - val decoded = source.through(decode[Fallible](Charset.`UTF-8`)).compile.string - decoded == Right(utf8Decoded) - } - } - } +import java.nio.ByteBuffer +import java.nio.charset.{ + CodingErrorAction, + MalformedInputException, + StandardCharsets, + UnmappableCharacterException, + Charset => JCharset +} +import scala.util.Try - test("decode should be consistent with String constructor over aggregated output") { - forAll { (cs: Charset, s: String, chunkSize: Int) => - // x-COMPOUND_TEXT fails with a read only buffer. - (chunkSize > 0 && cs.nioCharset.canEncode && cs.nioCharset.name != "x-COMPOUND_TEXT") ==> { - val source: Stream[Pure, Byte] = Stream - .emits { - s.getBytes(cs.nioCharset) - .grouped(chunkSize) - .map(Chunk.bytes) - .toSeq - } - .flatMap(Stream.chunk) - val expected = new String(source.toVector.toArray, cs.nioCharset) - !expected.contains("\ufffd") ==> { - // \ufffd means we generated a String unrepresentable by the charset - val decoded = source.through(decode[Fallible](cs)).compile.string - decoded == Right(expected) +class DecodeSpec extends Http4sSuite { + test("decode should be consistent with utf8.decode") { + forAll { (s: String, chunkSize: Int) => + (chunkSize > 0) ==> { + val source = Stream + .emits { + s.getBytes(StandardCharsets.UTF_8) + .grouped(chunkSize) + .map(_.toArray) + .map(Chunk.array[Byte]) + .toSeq } - } + .flatMap(Stream.chunk[Pure, Byte]) + val utf8Decoded = utf8.decode(source).toList.combineAll + val decoded = source.through(decode[Fallible](Charset.`UTF-8`)).compile.string + decoded == Right(utf8Decoded) } } + } - test("decode should decode an empty chunk") { - forAll { (cs: Charset) => - val source: Stream[Pure, Byte] = Stream.chunk[Pure, Byte](Chunk.empty[Byte]) + test("decode should be consistent with String constructor over aggregated output") { + forAll { (cs: Charset, s: String, chunkSize: Int) => + // x-COMPOUND_TEXT fails with a read only buffer. + (chunkSize > 0 && cs.nioCharset.canEncode && cs.nioCharset.name != "x-COMPOUND_TEXT") ==> { + val source: Stream[Pure, Byte] = Stream + .emits { + s.getBytes(cs.nioCharset) + .grouped(chunkSize) + .map(Chunk.array[Byte]) + .toSeq + } + .flatMap(Stream.chunk[Pure, Byte]) val expected = new String(source.toVector.toArray, cs.nioCharset) !expected.contains("\ufffd") ==> { // \ufffd means we generated a String unrepresentable by the charset @@ -84,73 +73,91 @@ class DecodeSpec extends Http4sSuite { } } } + } - test("decode should drop Byte Order Mark") { - val source = Stream(0xef.toByte, 0xbb.toByte, 0xbf.toByte) - val decoded = source.through(decode[Fallible](Charset.`UTF-8`)).compile.string - decoded == Right("") + test("decode should decode an empty chunk") { + forAll { (cs: Charset) => + val source: Stream[Pure, Byte] = Stream.chunk[Pure, Byte](Chunk.empty[Byte]) + val expected = new String(source.toVector.toArray, cs.nioCharset) + !expected.contains("\ufffd") ==> { + // \ufffd means we generated a String unrepresentable by the charset + val decoded = source.through(decode[Fallible](cs)).compile.string + decoded == Right(expected) + } } + } - test("decode should handle malformed input") { - // Not a valid first byte in UTF-8 - val source = Stream(0x80.toByte) - val decoded = source.through(decode[Fallible](Charset.`UTF-8`)).compile.string - assert(decoded match { - case Left(_: MalformedInputException) => true - case _ => false - }) - } + test("decode should drop Byte Order Mark") { + val source = Stream(0xef.toByte, 0xbb.toByte, 0xbf.toByte) + val decoded = source.through(decode[Fallible](Charset.`UTF-8`)).compile.string + decoded == Right("") + } - test("decode should handle incomplete input") { - // Only the first byte of a two-byte UTF-8 sequence - val source = Stream(0xc2.toByte) - val decoded = source.through(decode[Fallible](Charset.`UTF-8`)).compile.string - assert(decoded match { - case Left(_: MalformedInputException) => true - case _ => false - }) - } + test("decode should handle malformed input") { + // Not a valid first byte in UTF-8 + val source = Stream(0x80.toByte) + val decoded = source.through(decode[Fallible](Charset.`UTF-8`)).compile.string + assert(decoded match { + case Left(_: MalformedInputException) => true + case _ => false + }) + } - test("decode should handle unmappable character") { - // https://stackoverflow.com/a/22902806 - val source = Stream(0x80.toByte, 0x81.toByte) - val decoded = - source.through(decode[Fallible](Charset(JCharset.forName("IBM1098")))).compile.string - assert(decoded match { - case Left(_: UnmappableCharacterException) => true - case _ => false - }) - } + test("decode should handle incomplete input") { + // Only the first byte of a two-byte UTF-8 sequence + val source = Stream(0xc2.toByte) + val decoded = source.through(decode[Fallible](Charset.`UTF-8`)).compile.string + assert(decoded match { + case Left(_: MalformedInputException) => true + case _ => false + }) + } - test("decode should handle overflows") { - // Found by scalachek - val source = Stream(-36.toByte) - val decoded = - source.through(decode[Fallible](Charset(JCharset.forName("x-ISCII91")))).compile.string - assert(decoded == Right("ी")) - } + test("decode should handle unmappable character") { + // https://stackoverflow.com/a/22902806 + val source = Stream(0x80.toByte, 0x81.toByte) + val decoded = + source.through(decode[Fallible](Charset(JCharset.forName("IBM1098")))).compile.string + assert(decoded match { + case Left(_: UnmappableCharacterException) => true + case _ => false + }) + } - test("decode should not crash in IllegalStateException") { - // Found by scalachek - val source = Stream(-1.toByte) - val decoded = - source.through(decode[Fallible](Charset(JCharset.forName("x-IBM943")))).compile.string - assert(decoded match { - case Left(_: MalformedInputException) => true - case _ => false - }) - } + test("decode should handle overflows") { + // Found by scalachek + val source = Stream(-36.toByte) + val decoded = + source.through(decode[Fallible](Charset(JCharset.forName("x-ISCII91")))).compile.string + assert(decoded == Right("ी")) + } - test("decode should either succeed or raise a CharacterCodingException") { - forAll { (bs: Array[Byte], cs: Charset) => - val source = Stream.emits(bs) - val decoded = source.through(decode[Fallible](cs)).compile.drain - decoded match { - case Left(_: CharacterCodingException) => true - case Right(_) => true - case _ => false - } - } + test("decode should not crash in IllegalStateException") { + // Found by scalachek + val source = Stream(-1.toByte) + val decoded = + source.through(decode[Fallible](Charset(JCharset.forName("x-IBM943")))).compile.string + assert(decoded match { + case Left(_: MalformedInputException) => true + case _ => false + }) + } + + test("decode stream result should be consistent with nio's decode on full stream") { + forAll { (bs: Array[Byte], cs: Charset) => + val referenceDecoder = cs.nioCharset + .newDecoder() + // setting these to be consistent with our decoder's behavior + // note that java.nio.charset.Charset.decode and fs2's utf8.decode + // will replace character instead of raising exception + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + + val referenceResult = Try(referenceDecoder.decode(ByteBuffer.wrap(bs)).toString).toEither + val source = Stream.emits(bs) + val decoded = source.through(decode[Fallible](cs)).compile.foldMonoid + // Ignoring the actual exception type + assertEquals(decoded.toOption, referenceResult.toOption) } } } diff --git a/tests/src/test/scala/org/http4s/EntityCodecSuite.scala b/tests/src/test/scala/org/http4s/EntityCodecSuite.scala index 6d5edcd761d..13b466451ec 100644 --- a/tests/src/test/scala/org/http4s/EntityCodecSuite.scala +++ b/tests/src/test/scala/org/http4s/EntityCodecSuite.scala @@ -18,7 +18,7 @@ package org.http4s import cats.Eq import cats.effect.IO -import cats.effect.laws.util.TestContext +import cats.effect.testkit.TestContext import fs2.Chunk import org.http4s.laws.discipline.EntityCodecTests import org.http4s.testing.fs2Arbitraries._ diff --git a/tests/src/test/scala/org/http4s/EntityDecoderSuite.scala b/tests/src/test/scala/org/http4s/EntityDecoderSuite.scala index 4ca0dc5dc9b..45b251c0345 100644 --- a/tests/src/test/scala/org/http4s/EntityDecoderSuite.scala +++ b/tests/src/test/scala/org/http4s/EntityDecoderSuite.scala @@ -40,7 +40,7 @@ class EntityDecoderSuite extends Http4sSuite { body.compile.toVector.map(_.toArray) def strBody(body: String): Stream[IO, Byte] = - chunk(Chunk.bytes(body.getBytes(StandardCharsets.UTF_8))) + chunk(Chunk.array(body.getBytes(StandardCharsets.UTF_8))) val req = Response[IO](Ok).withEntity("foo").pure[IO] test("flatMapR with success") { @@ -328,7 +328,7 @@ class EntityDecoderSuite extends Http4sSuite { val request = Request[IO]().withEntity("whatever") - test("apply should invoke the function with the right on a success") { + test("apply should invoke the function with the right on a success") { val happyDecoder: EntityDecoder[IO, String] = EntityDecoder.decodeBy(MediaRange.`*/*`)(_ => DecodeResult.success(IO.pure("hooray"))) IO.async[String] { cb => @@ -337,8 +337,7 @@ class EntityDecoderSuite extends Http4sSuite { cb(Right(s)) IO.pure(Response()) } - .unsafeRunSync() - () + .as(None) }.assertEquals("hooray") } @@ -389,14 +388,14 @@ class EntityDecoderSuite extends Http4sSuite { val binData: Array[Byte] = "Bytes 10111".getBytes - def readFile(in: File): IO[Array[Byte]] = IO { + def readFile(in: File): IO[Array[Byte]] = IO.blocking { val os = new FileInputStream(in) val data = new Array[Byte](in.length.asInstanceOf[Int]) os.read(data) data } - def readTextFile(in: File): IO[String] = IO { + def readTextFile(in: File): IO[String] = IO.blocking { val os = new InputStreamReader(new FileInputStream(in)) val data = new Array[Char](in.length.asInstanceOf[Int]) os.read(data, 0, in.length.asInstanceOf[Int]) @@ -404,14 +403,14 @@ class EntityDecoderSuite extends Http4sSuite { } def mockServe(req: Request[IO])(route: Request[IO] => IO[Response[IO]]) = - route(req.withBodyStream(chunk(Chunk.bytes(binData)))) + route(req.withBodyStream(chunk(Chunk.array(binData)))) test("A File EntityDecoder should write a text file from a byte string") { Resource .make(IO(File.createTempFile("foo", "bar")))(f => IO(f.delete()).void) .use { tmpFile => val response = mockServe(Request()) { req => - req.decodeWith(EntityDecoder.textFile(tmpFile, testBlocker), strict = false) { _ => + req.decodeWith(EntityDecoder.textFile(tmpFile), strict = false) { _ => Response[IO](Ok).withEntity("Hello").pure[IO] } } @@ -428,7 +427,7 @@ class EntityDecoderSuite extends Http4sSuite { .make(IO(File.createTempFile("foo", "bar")))(f => IO(f.delete()).void) .use { tmpFile => val response = mockServe(Request()) { case req => - req.decodeWith(EntityDecoder.binFile(tmpFile, testBlocker), strict = false) { _ => + req.decodeWith(EntityDecoder.binFile(tmpFile), strict = false) { _ => Response[IO](Ok).withEntity("Hello").pure[IO] } } @@ -455,9 +454,9 @@ class EntityDecoderSuite extends Http4sSuite { test("binary EntityDecoder should concat Chunks") { val d1 = Array[Byte](1, 2, 3); val d2 = Array[Byte](4, 5, 6) - val body = chunk(Chunk.bytes(d1)) ++ chunk(Chunk.bytes(d2)) + val body = chunk(Chunk.array(d1)) ++ chunk(Chunk.array(d2)) val msg = Request[IO](body = body) - val expected = Chunk.bytes(Array[Byte](1, 2, 3, 4, 5, 6)) + val expected = Chunk.array(Array[Byte](1, 2, 3, 4, 5, 6)) EntityDecoder.binary[IO].decode(msg, strict = false).value.assertEquals(Right(expected)) } @@ -484,7 +483,7 @@ class EntityDecoderSuite extends Http4sSuite { sealed case class ErrorJson(value: String) implicit val errorJsonEntityEncoder: EntityEncoder[IO, ErrorJson] = EntityEncoder.simple[IO, ErrorJson](`Content-Type`(MediaType.application.json))(json => - Chunk.bytes(json.value.getBytes())) + Chunk.array(json.value.getBytes())) // TODO: These won't work without an Eq for (Message[IO], Boolean) => DecodeResult[IO, A] // { diff --git a/tests/src/test/scala/org/http4s/EntityEncoderSpec.scala b/tests/src/test/scala/org/http4s/EntityEncoderSpec.scala index 59c3b2eca93..ded50d0dc2b 100644 --- a/tests/src/test/scala/org/http4s/EntityEncoderSpec.scala +++ b/tests/src/test/scala/org/http4s/EntityEncoderSpec.scala @@ -16,19 +16,18 @@ package org.http4s -import cats.Eq +import cats._ import cats.effect.IO -import cats.syntax.all._ import cats.laws.discipline.{ContravariantTests, ExhaustiveCheck, MiniInt} import cats.laws.discipline.eq._ -import cats.laws.discipline.arbitrary._ import fs2._ +import cats.laws.discipline.arbitrary._ + import java.io._ import java.nio.charset.StandardCharsets -import java.util.concurrent.TimeoutException import org.http4s.headers._ import org.http4s.laws.discipline.arbitrary._ -import scala.concurrent.duration._ +import org.scalacheck.Arbitrary class EntityEncoderSpec extends Http4sSuite { { @@ -80,7 +79,7 @@ class EntityEncoderSpec extends Http4sSuite { val w = new FileWriter(tmpFile) try w.write("render files test") finally w.close() - writeToString(tmpFile)(EntityEncoder.fileEncoder(testBlocker)) + writeToString(tmpFile)(EntityEncoder.fileEncoder) .guarantee(IO.delay(tmpFile.delete()).void) .assertEquals("render files test") @@ -88,13 +87,13 @@ class EntityEncoderSpec extends Http4sSuite { test("EntityEncoder should render input streams") { val inputStream = new ByteArrayInputStream("input stream".getBytes(StandardCharsets.UTF_8)) - writeToString(IO(inputStream))(EntityEncoder.inputStreamEncoder(testBlocker)) + writeToString(IO(inputStream))(EntityEncoder.inputStreamEncoder) .assertEquals("input stream") } test("EntityEncoder should render readers") { val reader = new StringReader("string reader") - writeToString(IO(reader))(EntityEncoder.readerEncoder(testBlocker)) + writeToString(IO(reader))(EntityEncoder.readerEncoder) .assertEquals("string reader") } @@ -103,14 +102,14 @@ class EntityEncoderSpec extends Http4sSuite { // This is reproducible on input streams val longString = "string reader" * 5000 val reader = new StringReader(longString) - writeToString[IO[Reader]](IO(reader))(EntityEncoder.readerEncoder(testBlocker)) + writeToString[IO[Reader]](IO(reader))(EntityEncoder.readerEncoder) .assertEquals(longString) } test("EntityEncoder should render readers with UTF chars") { val utfString = "A" + "\u08ea" + "\u00f1" + "\u72fc" + "C" val reader = new StringReader(utfString) - writeToString[IO[Reader]](IO(reader))(EntityEncoder.readerEncoder(testBlocker)) + writeToString[IO[Reader]](IO(reader))(EntityEncoder.readerEncoder) .assertEquals(utfString) } @@ -128,9 +127,9 @@ class EntityEncoderSpec extends Http4sSuite { sealed case class ModelB(name: String, id: Long) implicit val w1: EntityEncoder[IO, ModelA] = - EntityEncoder.simple[IO, ModelA]()(_ => Chunk.bytes("A".getBytes)) + EntityEncoder.simple[IO, ModelA]()(_ => Chunk.array("A".getBytes)) implicit val w2: EntityEncoder[IO, ModelB] = - EntityEncoder.simple[IO, ModelB]()(_ => Chunk.bytes("B".getBytes)) + EntityEncoder.simple[IO, ModelB]()(_ => Chunk.array("B".getBytes)) assertEquals(EntityEncoder[IO, ModelA], w1) assertEquals(EntityEncoder[IO, ModelB], w2) @@ -138,25 +137,24 @@ class EntityEncoderSpec extends Http4sSuite { } { - implicit val throwableEq: Eq[Throwable] = - Eq.fromUniversalEquals - - implicit def entityEq: Eq[Entity[IO]] = - Eq.by[Entity[IO], Either[Throwable, (Option[Long], Vector[Byte])]] { entity => - entity.body.compile.toVector - .map(bytes => (entity.length, bytes)) - .attempt - .unsafeRunTimed(1.second) - .getOrElse(throw new TimeoutException) + implicit def entityEq: Eq[Entity[Id]] = + Eq.by[Entity[Id], (Option[Long], Vector[Byte])] { entity => + (entity.length, entity.body.compile.toVector) } - implicit def entityEncoderEq[A: ExhaustiveCheck]: Eq[EntityEncoder[IO, A]] = - Eq.by[EntityEncoder[IO, A], (Headers, A => Entity[IO])] { enc => + implicit def entityEncoderEq[A: ExhaustiveCheck]: Eq[EntityEncoder[Id, A]] = + Eq.by[EntityEncoder[Id, A], (Headers, A => Entity[Id])] { enc => (enc.headers, enc.toEntity) } + // todo this is needed for scala 2.12, remove once we no longer support it + implicit def contravariant: Contravariant[EntityEncoder[Id, *]] = + EntityEncoder.entityEncoderContravariant[Id] + implicit def arb[A: org.scalacheck.Cogen]: Arbitrary[EntityEncoder[Id, A]] = + http4sTestingArbitraryForEntityEncoder[Id, A] + checkAll( "Contravariant[EntityEncoder[F, *]]", - ContravariantTests[EntityEncoder[IO, *]].contravariant[MiniInt, MiniInt, MiniInt]) + ContravariantTests[EntityEncoder[Id, *]].contravariant[MiniInt, MiniInt, MiniInt]) } } diff --git a/tests/src/test/scala/org/http4s/Ipv6AddressSuite.scala b/tests/src/test/scala/org/http4s/Ipv6AddressSuite.scala index cc4a7ed8939..6aee3d13a6e 100644 --- a/tests/src/test/scala/org/http4s/Ipv6AddressSuite.scala +++ b/tests/src/test/scala/org/http4s/Ipv6AddressSuite.scala @@ -23,6 +23,7 @@ import org.http4s.laws.discipline.HttpCodecTests import org.http4s.laws.discipline.arbitrary._ import org.http4s.util.Renderer.renderString import org.scalacheck.Prop._ +import java.net.Inet6Address class Ipv6AddressSuite extends Http4sSuite { checkAll("Order[Ipv6Address]", OrderTests[Ipv6Address].order) @@ -48,7 +49,7 @@ class Ipv6AddressSuite extends Http4sSuite { test("fromInet6Address should round trip with toInet6Address") { forAll { (ipv6: Ipv6Address) => - Ipv6Address.fromInet6Address(ipv6.toInet6Address) == ipv6 + Ipv6Address.fromInet6Address(ipv6.toInetAddress.asInstanceOf[Inet6Address]) == ipv6 } } diff --git a/tests/src/test/scala/org/http4s/MessageSuite.scala b/tests/src/test/scala/org/http4s/MessageSuite.scala index 4dc395fb20f..4f2b3237c1b 100644 --- a/tests/src/test/scala/org/http4s/MessageSuite.scala +++ b/tests/src/test/scala/org/http4s/MessageSuite.scala @@ -278,7 +278,7 @@ class MessageSuite extends Http4sSuite { assertEquals(resp.contentType, Some(`Content-Type`(MediaType.text.plain, Charset.`UTF-8`))) assertEquals(resp.status, Status.NotFound) - assertEquals(resp.body.through(fs2.text.utf8Decode).toList.mkString(""), "Not found") + assertEquals(resp.body.through(fs2.text.utf8.decode).toList.mkString(""), "Not found") } // todo compiles on dotty diff --git a/tests/src/test/scala/org/http4s/ServerSentEventSpec.scala b/tests/src/test/scala/org/http4s/ServerSentEventSpec.scala index 9d710227c0e..3e642109f3f 100644 --- a/tests/src/test/scala/org/http4s/ServerSentEventSpec.scala +++ b/tests/src/test/scala/org/http4s/ServerSentEventSpec.scala @@ -19,7 +19,7 @@ package org.http4s import cats.effect.IO import cats.implicits.catsSyntaxOptionId import fs2.Stream -import fs2.text.utf8Encode +import fs2.text.utf8 import org.http4s.headers._ import org.http4s.laws.discipline.ArbitraryInstances._ import org.scalacheck.effect._ @@ -32,7 +32,7 @@ class ServerSentEventSpec extends Http4sSuite { def toStream(s: String): Stream[IO, Byte] = { val scrubbed = s.dropWhile(_.isWhitespace) - Stream.emit(scrubbed).through(utf8Encode) + Stream.emit(scrubbed).through(utf8.encode) } test("decode should decode multi-line messages") { diff --git a/tests/src/test/scala/org/http4s/StaticFileSuite.scala b/tests/src/test/scala/org/http4s/StaticFileSuite.scala index b9c375d8630..b217cf9794d 100644 --- a/tests/src/test/scala/org/http4s/StaticFileSuite.scala +++ b/tests/src/test/scala/org/http4s/StaticFileSuite.scala @@ -30,7 +30,7 @@ import java.net.UnknownHostException class StaticFileSuite extends Http4sSuite { test("Determine the media-type based on the files extension") { def check(f: File, tpe: Option[MediaType]): IO[Boolean] = - StaticFile.fromFile[IO](f, testBlocker).value.map { r => + StaticFile.fromFile[IO](f).value.map { r => r.isDefined && r.flatMap(_.headers.get[`Content-Type`]) == tpe.map(t => `Content-Type`(t)) && // Other headers must be present @@ -49,7 +49,7 @@ class StaticFileSuite extends Http4sSuite { test("load from resource") { def check(resource: String, status: Status): IO[Unit] = { val res1 = StaticFile - .fromResource[IO](resource, testBlocker) + .fromResource[IO](resource) .value Nested(res1) @@ -82,7 +82,7 @@ class StaticFileSuite extends Http4sSuite { def check(resource: String, status: Status): IO[Unit] = { val res1 = StaticFile - .fromResource[IO](resource, testBlocker, classloader = Some(loader)) + .fromResource[IO](resource, classloader = Some(loader)) .value Nested(res1).map(_.status).value.map(_.getOrElse(NotFound)).assertEquals(status) @@ -105,7 +105,7 @@ class StaticFileSuite extends Http4sSuite { test("handle an empty file") { val emptyFile = File.createTempFile("empty", ".tmp") - StaticFile.fromFile[IO](emptyFile, testBlocker).value.map(_.isDefined).assert + StaticFile.fromFile[IO](emptyFile).value.map(_.isDefined).assert } test("Don't send unmodified files") { @@ -114,7 +114,7 @@ class StaticFileSuite extends Http4sSuite { val request = Request[IO]().putHeaders(`If-Modified-Since`(HttpDate.MaxValue)) val response = StaticFile - .fromFile[IO](emptyFile, testBlocker, Some(request)) + .fromFile[IO](emptyFile, Some(request)) .value Nested(response).map(_.status).value.assertEquals(Some(NotModified)) } @@ -127,7 +127,7 @@ class StaticFileSuite extends Http4sSuite { `If-None-Match`( EntityTag(s"${emptyFile.lastModified().toHexString}-${emptyFile.length().toHexString}"))) val response = StaticFile - .fromFile[IO](emptyFile, testBlocker, Some(request)) + .fromFile[IO](emptyFile, Some(request)) .value Nested(response).map(_.status).value.assertEquals(Some(NotModified)) } @@ -142,7 +142,7 @@ class StaticFileSuite extends Http4sSuite { EntityTag(s"${emptyFile.lastModified().toHexString}-${emptyFile.length().toHexString}"))) val response = StaticFile - .fromFile[IO](emptyFile, testBlocker, Some(request)) + .fromFile[IO](emptyFile, Some(request)) .value Nested(response).map(_.status).value.assertEquals(Some(NotModified)) } @@ -155,7 +155,7 @@ class StaticFileSuite extends Http4sSuite { .putHeaders(`If-Modified-Since`(HttpDate.MaxValue), `If-None-Match`(EntityTag(s"12345"))) val response = StaticFile - .fromFile[IO](emptyFile, testBlocker, Some(request)) + .fromFile[IO](emptyFile, Some(request)) .value Nested(response).map(_.status).value.assertEquals(Some(Ok)) } @@ -172,7 +172,7 @@ class StaticFileSuite extends Http4sSuite { s"${emptyFile.lastModified().toHexString}-${emptyFile.length().toHexString}"))) val response = StaticFile - .fromFile[IO](emptyFile, testBlocker, Some(request)) + .fromFile[IO](emptyFile, Some(request)) .value Nested(response).map(_.status).value.assertEquals(Some(Ok)) } @@ -181,14 +181,7 @@ class StaticFileSuite extends Http4sSuite { def check(path: String): IO[Unit] = IO(new File(path)).flatMap { f => StaticFile - .fromFile[IO]( - f, - 0, - 1, - StaticFile.DefaultBufferSize, - testBlocker, - None, - StaticFile.calcETag[IO]) + .fromFile[IO](f, 0, 1, StaticFile.DefaultBufferSize, None, StaticFile.calcETag[IO]) .value .flatMap { r => // Length is only 1 byte @@ -221,10 +214,9 @@ class StaticFileSuite extends Http4sSuite { StaticFile .fromFile[IO]( file, - 0, - fileSize.toLong - 1, - StaticFile.DefaultBufferSize, - testBlocker, + start = 0, + end = fileSize.toLong - 1, + buffsize = StaticFile.DefaultBufferSize, None, StaticFile.calcETag[IO]) .value @@ -256,7 +248,7 @@ class StaticFileSuite extends Http4sSuite { val url = getClass.getResource("/lorem-ipsum.txt") val expected = scala.io.Source.fromURL(url, "utf-8").mkString val s = StaticFile - .fromURL[IO](getClass.getResource("/lorem-ipsum.txt"), testBlocker) + .fromURL[IO](getClass.getResource("/lorem-ipsum.txt")) .value .map(_.fold[EntityBody[IO]](sys.error("Couldn't find resource"))(_.body)) // Expose problem with readInputStream recycling buffer. chunks.compile.toVector @@ -270,7 +262,7 @@ class StaticFileSuite extends Http4sSuite { val url = getClass.getResource("/lorem-ipsum.txt") val len = StaticFile - .fromURL[IO](url, testBlocker) + .fromURL[IO](url) .value .map(_.flatMap(_.contentLength)) len.assertEquals(Some(24005L)) @@ -279,7 +271,7 @@ class StaticFileSuite extends Http4sSuite { test("return none from a file URL that is a directory") { // val url = getClass.getResource("/foo") StaticFile - .fromURL[IO](getClass.getResource("/foo"), testBlocker) + .fromURL[IO](getClass.getResource("/foo")) .value .assertEquals(None) } @@ -292,7 +284,7 @@ class StaticFileSuite extends Http4sSuite { // Or we can be lazy and just use `/`. assume(new File("/").isDirectory, "/ is not a directory") StaticFile - .fromURL[IO](new URL("https://github.com//"), testBlocker) + .fromURL[IO](new URL("https://github.com//")) .value .map(_.fold(Status.NotFound)(_.status)) .assertEquals(Status.Ok) @@ -300,14 +292,14 @@ class StaticFileSuite extends Http4sSuite { test("return none from a URL that points to a resource that does not exist") { StaticFile - .fromURL[IO](new URL("https://github.com/http4s/http4s/fooz"), testBlocker) + .fromURL[IO](new URL("https://github.com/http4s/http4s/fooz")) .value .assertEquals(None) } test("raise exception when url does not exist") { StaticFile - .fromURL[IO](new URL("https://quuzgithubfoo.com/http4s/http4s/fooz"), testBlocker) + .fromURL[IO](new URL("https://quuzgithubfoo.com/http4s/http4s/fooz")) .value .intercept[UnknownHostException] } diff --git a/tests/src/test/scala/org/http4s/multipart/MultipartParserSuite.scala b/tests/src/test/scala/org/http4s/multipart/MultipartParserSuite.scala index eb9dc7fe6b8..4024fa9c438 100644 --- a/tests/src/test/scala/org/http4s/multipart/MultipartParserSuite.scala +++ b/tests/src/test/scala/org/http4s/multipart/MultipartParserSuite.scala @@ -14,10 +14,12 @@ * limitations under the License. */ -package org.http4s.multipart +package org.http4s +package multipart import cats.effect._ -import cats.effect.concurrent.Ref +import cats.effect.std._ +import cats.implicits._ import cats.instances.string._ import fs2._ import org.http4s._ @@ -26,9 +28,10 @@ import org.http4s.util._ import org.typelevel.ci._ import java.nio.charset.StandardCharsets +import java.nio.file.NoSuchFileException +import scala.annotation.nowarn class MultipartParserSuite extends Http4sSuite { - implicit val contextShift: ContextShift[IO] = Http4sSuite.TestContextShift val boundary = Boundary("_5PHqf8_Pl1FCzBuT5o_mVZg36k67UYI") @@ -43,10 +46,10 @@ class MultipartParserSuite extends Http4sSuite { def jumbleAccum(s: String, acc: Stream[IO, Byte]): Stream[IO, Byte] = if (s.length <= 1) - acc ++ Stream.chunk(Chunk.bytes(s.getBytes())) + acc ++ Stream.chunk(Chunk.array(s.getBytes())) else { val (l, r) = s.splitAt(rand.nextInt(s.length - 1) + 1) - jumbleAccum(r, acc ++ Stream.chunk(Chunk.bytes(l.getBytes))) + jumbleAccum(r, acc ++ Stream.chunk(Chunk.array(l.getBytes))) } jumbleAccum(str, Stream.empty) @@ -69,7 +72,20 @@ class MultipartParserSuite extends Http4sSuite { testName: String, multipartPipe: Boundary => Pipe[IO, Byte, Multipart[IO]], limitedPipe: (Boundary, Int) => Pipe[IO, Byte, Multipart[IO]], - partsPipe: Boundary => Pipe[IO, Byte, Part[IO]])(implicit loc: munit.Location): Unit = { + partsPipe: Boundary => Pipe[IO, Byte, Part[IO]])(implicit loc: munit.Location): Unit = + multipartParserResourceTests( + testName, + boundary => Resource.pure(multipartPipe(boundary)), + (boundary, limit) => Resource.pure(limitedPipe(boundary, limit)), + boundary => Resource.pure(partsPipe(boundary)) + ) + + def multipartParserResourceTests( + testName: String, + mkMultipartPipe: Boundary => Resource[IO, Pipe[IO, Byte, Multipart[IO]]], + mkLimitedPipe: (Boundary, Int) => Resource[IO, Pipe[IO, Byte, Multipart[IO]]], + mkPartsPipe: Boundary => Resource[IO, Pipe[IO, Byte, Part[IO]]])(implicit + loc: munit.Location): Unit = { val testNamePrefix = s"form streaming parsing for $testName" @@ -103,23 +119,27 @@ class MultipartParserSuite extends Http4sSuite { |catch me if you can! |""".stripMargin) - val results = - unspool(input, chunkSize).through(multipartPipe(boundary)) + val mkResults = + mkMultipartPipe(boundary).map( + _(unspool(input, chunkSize)) + ) - for { - multipartMaterialized <- results.compile.last.map(_.get) - headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) - bodies = multipartMaterialized.parts - .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) - .through(asciiDecode) - .compile - .foldMonoid - result <- bodies.attempt - } yield { - headers.headers.foreach(h => println(">> " + h)) - expectedHeaders.headers.foreach(h => println("<< " + h)) - assertEquals(headers, expectedHeaders) - assertEquals(result, Right(expected)) + mkResults.use { results => + for { + multipartMaterialized <- results.compile.last.map(_.get) + headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) + bodies = multipartMaterialized.parts + .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) + .through(asciiDecode) + .compile + .foldMonoid + result <- bodies.attempt + } yield { + headers.headers.foreach(h => println(">> " + h)) + expectedHeaders.headers.foreach(h => println("<< " + h)) + assertEquals(headers, expectedHeaders) + assertEquals(result, Right(expected)) + } } } @@ -139,8 +159,10 @@ class MultipartParserSuite extends Http4sSuite { |--_5PHqf8_Pl1FCzBuT5o_mVZg36k67UYI--""".stripMargin val input = ruinDelims(unprocessedInput) - val results = - unspool(input, 15).through(multipartPipe(boundary)) + val mkResults = + mkMultipartPipe(boundary).map( + _(unspool(input, 15)) + ) val expectedHeaders = Headers( `Content-Disposition`( @@ -155,19 +177,21 @@ class MultipartParserSuite extends Http4sSuite { |catch me if you can! |""".stripMargin) - for { - multipartMaterialized <- results.compile.last.map(_.get) - headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) - bodies = - multipartMaterialized.parts - .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) - .through(asciiDecode) - .compile - .foldMonoid - result <- bodies.attempt - } yield { - assertEquals(headers, expectedHeaders) - assertEquals(result, Right(expected)) + mkResults.use { results => + for { + multipartMaterialized <- results.compile.last.map(_.get) + headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) + bodies = + multipartMaterialized.parts + .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) + .through(asciiDecode) + .compile + .foldMonoid + result <- bodies.attempt + } yield { + assertEquals(headers, expectedHeaders) + assertEquals(result, Right(expected)) + } } } @@ -185,9 +209,10 @@ class MultipartParserSuite extends Http4sSuite { |--_5PHqf8_Pl1FCzBuT5o_mVZg36k67UYI--""".stripMargin val input = ruinDelims(unprocessedInput) - val results = - unspool(input, 15, StandardCharsets.UTF_8) - .through(multipartPipe(boundary)) + val mkResults = + mkMultipartPipe(boundary).map( + _(unspool(input, 15, StandardCharsets.UTF_8)) + ) val expectedHeaders = Headers( "Content-Disposition" -> """form-data; name*="http4s很棒"; filename*="我老婆太漂亮.txt"""", @@ -200,20 +225,22 @@ class MultipartParserSuite extends Http4sSuite { |catch me if you can! |""".stripMargin) - for { - multipartMaterialized <- results.compile.last.map(_.get) - headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) - bodies = - multipartMaterialized.parts - .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) - .through(asciiDecode) - .compile - .foldMonoid - result <- bodies.attempt - } yield { + mkResults.use { results => + for { + multipartMaterialized <- results.compile.last.map(_.get) + headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) + bodies = + multipartMaterialized.parts + .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) + .through(asciiDecode) + .compile + .foldMonoid + result <- bodies.attempt + } yield { - assertEquals(headers, expectedHeaders) - assertEquals(result, Right(expected)) + assertEquals(headers, expectedHeaders) + assertEquals(result, Right(expected)) + } } } @@ -231,9 +258,10 @@ class MultipartParserSuite extends Http4sSuite { |--_5PHqf8_Pl1FCzBuT5o_mVZg36k67UYI--""".stripMargin val input = ruinDelims(unprocessedInput) - val results = - unspool(input, 15, StandardCharsets.UTF_8) - .through(multipartPipe(boundary)) + val mkResults = + mkMultipartPipe(boundary).map( + _(unspool(input, 15, StandardCharsets.UTF_8)) + ) val expectedHeaders = Headers( // #4513 for why this isn't a modeled header @@ -249,19 +277,21 @@ class MultipartParserSuite extends Http4sSuite { |catch me if you can! |""".stripMargin) - for { - multipartMaterialized <- results.compile.last.map(_.get) - headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) - bodies = - multipartMaterialized.parts - .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) - .through(asciiDecode) - .compile - .foldMonoid - result <- bodies.attempt - } yield { - assertEquals(headers, expectedHeaders) - assertEquals(result, Right(expected)) + mkResults.use { results => + for { + multipartMaterialized <- results.compile.last.map(_.get) + headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) + bodies = + multipartMaterialized.parts + .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) + .through(asciiDecode) + .compile + .foldMonoid + result <- bodies.attempt + } yield { + assertEquals(headers, expectedHeaders) + assertEquals(result, Right(expected)) + } } } @@ -297,38 +327,43 @@ class MultipartParserSuite extends Http4sSuite { Stream .constant("Misery is the river of the world") .take(10) - .through(text.utf8Encode) + .through(text.utf8.encode) val crlf: Stream[IO, Byte] = Stream .emit(Boundary.CRLF) - .through(text.utf8Encode) + .through(text.utf8.encode) val epilogue: Stream[IO, Byte] = Stream .constant("Everybody Row!\n") .take(10) - .through(text.utf8Encode) - - val results = ( - preamble ++ - crlf ++ - unspool(input, 15) ++ - epilogue - ).through(multipartPipe(boundary)) - - for { - multipartMaterialized <- results.compile.last.map(_.get) - headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) - bodies = multipartMaterialized.parts - .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) - .through(asciiDecode) - .compile - .foldMonoid - result <- bodies.attempt - } yield { - assertEquals(headers, expectedHeaders) - assertEquals(result, Right(expected)) + .through(text.utf8.encode) + + val mkResults = + mkMultipartPipe(boundary).map( + _( + preamble ++ + crlf ++ + unspool(input, 15) ++ + epilogue + ) + ) + + mkResults.use { results => + for { + multipartMaterialized <- results.compile.last.map(_.get) + headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) + bodies = multipartMaterialized.parts + .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) + .through(asciiDecode) + .compile + .foldMonoid + result <- bodies.attempt + } yield { + assertEquals(headers, expectedHeaders) + assertEquals(result, Right(expected)) + } } } @@ -356,12 +391,16 @@ class MultipartParserSuite extends Http4sSuite { |Content-Transfer-Encoding: binary""".stripMargin val maxSize = ruinDelims(headerSection).length - val results = - unspool(input, 15).through(limitedPipe(boundary, maxSize)) + val mkResults = + mkLimitedPipe(boundary, maxSize).map( + _(unspool(input, 15)) + ) - results.compile.toVector - .interceptMessage[MalformedMessageBodyFailure]( - s"Malformed message body: Part header was longer than $maxSize-byte limit") + mkResults.use { results => + results.compile.toVector + .interceptMessage[MalformedMessageBodyFailure]( + s"Malformed message body: Part header was longer than $maxSize-byte limit") + } } test(s"$testNamePrefix: handle a miserably large body on one line") { @@ -385,24 +424,28 @@ class MultipartParserSuite extends Http4sSuite { val crlf: Stream[IO, Byte] = Stream .emit(Boundary.CRLF) - .through(text.utf8Encode) + .through(text.utf8.encode) val body: Stream[IO, Byte] = Stream .constant("Misery is the river of the world") .take(100000) - .through(text.utf8Encode) - - val results = ( - unspool(input) ++ - body ++ - crlf ++ - unspool(end) - ).through(multipartPipe(boundary)) + .through(text.utf8.encode) + + val mkResults = mkMultipartPipe(boundary).map( + _( + unspool(input) ++ + body ++ + crlf ++ + unspool(end) + ) + ) - results.compile.last - .map(_.get) - .map(_.parts.foldLeft(Headers.empty)(_ ++ _.headers)) - .assertEquals(expectedHeaders) + mkResults.use { results => + results.compile.last + .map(_.get) + .map(_.parts.foldLeft(Headers.empty)(_ ++ _.headers)) + .assertEquals(expectedHeaders) + } } test(s"$testNamePrefix: produce the body from a single part input of one chunk") { @@ -434,19 +477,25 @@ class MultipartParserSuite extends Http4sSuite { |catch me if you can! |""".stripMargin) - val results = unspool(input).through(multipartPipe(boundary)) - for { - multipartMaterialized <- results.compile.last.map(_.get) - headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) - bodies = multipartMaterialized.parts - .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) - .through(asciiDecode) - .compile - .foldMonoid - result <- bodies.attempt - } yield { - assertEquals(headers, expectedHeaders) - assertEquals(result, Right(expected)) + val mkResults = + mkMultipartPipe(boundary).map( + _(unspool(input)) + ) + + mkResults.use { results => + for { + multipartMaterialized <- results.compile.last.map(_.get) + headers = multipartMaterialized.parts.foldLeft(Headers.empty)(_ ++ _.headers) + bodies = multipartMaterialized.parts + .foldLeft(Stream.empty.covary[IO]: Stream[IO, Byte])(_ ++ _.body) + .through(asciiDecode) + .compile + .foldMonoid + result <- bodies.attempt + } yield { + assertEquals(headers, expectedHeaders) + assertEquals(result, Right(expected)) + } } } @@ -470,16 +519,22 @@ class MultipartParserSuite extends Http4sSuite { val input = ruinDelims(unprocessedInput) - val results = unspool(input).through(multipartPipe(boundary)) - results.compile.last - .map(_.get) - .map( - _.parts(1).body - .through(asciiDecode) - .compile - .foldMonoid) - .flatMap(_.attempt) - .assertEquals(Right("bar")) + val mkResults = + mkMultipartPipe(boundary).map( + _(unspool(input)) + ) + + mkResults.use { results => + results.compile.last + .map(_.get) + .map( + _.parts(1).body + .through(asciiDecode) + .compile + .foldMonoid) + .flatMap(_.attempt) + .assertEquals(Right("bar")) + } } test(s"$testNamePrefix: parse uneven input properly") { @@ -507,16 +562,22 @@ class MultipartParserSuite extends Http4sSuite { .flatMap(Stream.chunk) .covary[IO] - val results = unprocessed.through(multipartPipe(boundary)) - results.compile.last - .map(_.get) - .map( - _.parts(1).body - .through(asciiDecode) - .compile - .foldMonoid) - .flatMap(_.attempt) - .assertEquals(Right("bar")) + val mkResults = + mkMultipartPipe(boundary).map( + _(unprocessed) + ) + + mkResults.use { results => + results.compile.last + .map(_.get) + .map( + _.parts(1).body + .through(asciiDecode) + .compile + .foldMonoid) + .flatMap(_.attempt) + .assertEquals(Right("bar")) + } } def parseRandomizedChunkLength(count: Int): Unit = @@ -540,16 +601,22 @@ class MultipartParserSuite extends Http4sSuite { val unprocessed = jumble(unprocessedInput) - val results = unprocessed.through(multipartPipe(boundary)) - results.compile.last - .map(_.get) - .map( - _.parts(1).body - .through(asciiDecode) - .compile - .foldMonoid) - .flatMap(_.attempt) - .assertEquals(Right("bar")) + val mkResults = + mkMultipartPipe(boundary).map( + _(unprocessed) + ) + + mkResults.use { results => + results.compile.last + .map(_.get) + .map( + _.parts(1).body + .through(asciiDecode) + .compile + .foldMonoid) + .flatMap(_.attempt) + .assertEquals(Right("bar")) + } } List.range(0, 100).foreach(parseRandomizedChunkLength) @@ -570,22 +637,27 @@ class MultipartParserSuite extends Http4sSuite { val input = ruinDelims(unprocessedInput) val boundaryTest = Boundary("RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0") - val results = unspool(input).through(multipartPipe(boundaryTest)) + val mkResults = + mkMultipartPipe(boundaryTest).map( + _(unspool(input)) + ) - results.compile.last - .map(_.get) - .map(_.parts.foldLeft(List.empty[Headers])((l, r) => l ::: List(r.headers))) - .assertEquals( - List( - Headers( - `Content-Disposition`("form-data", Map(ci"name" -> "field1")), - `Content-Type`(MediaType.text.plain) - ), - Headers( - `Content-Disposition`("form-data", Map(ci"name" -> "field2")) + mkResults.use { results => + results.compile.last + .map(_.get) + .map(_.parts.foldLeft(List.empty[Headers])((l, r) => l ::: List(r.headers))) + .assertEquals( + List( + Headers( + `Content-Disposition`("form-data", Map(ci"name" -> "field1")), + `Content-Type`(MediaType.text.plain) + ), + Headers( + `Content-Disposition`("form-data", Map(ci"name" -> "field2")) + ) ) ) - ) + } } test(s"$testNamePrefix: parse parts lazily") { @@ -607,26 +679,31 @@ class MultipartParserSuite extends Http4sSuite { val input = ruinDelims(unprocessedInput) val boundaryTest = Boundary("RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0") - val results = unspool(input).through(partsPipe(boundaryTest)) - - for { - firstPart <- results.take(1).compile.last.map(_.get) - confirmedError <- results.compile.drain.attempt - _ <- firstPart.body - .through(text.utf8Decode[IO]) - .compile - .foldMonoid - } yield { - assertEquals( - firstPart.headers, - Headers( - `Content-Disposition`("form-data", Map(ci"name" -> "field1")), - `Content-Type`(MediaType.text.plain))) - assert(confirmedError.isInstanceOf[Left[_, _]]) - assert( - confirmedError.left - .getOrElse(throw new Exception) - .isInstanceOf[MalformedMessageBodyFailure]) + val mkResults = + mkPartsPipe(boundaryTest).map( + _(unspool(input)) + ) + + mkResults.use { results => + for { + firstPart <- results.take(1).compile.last.map(_.get) + confirmedError <- results.compile.drain.attempt + _ <- firstPart.body + .through(text.utf8.decode[IO]) + .compile + .foldMonoid + } yield { + assertEquals( + firstPart.headers, + Headers( + `Content-Disposition`("form-data", Map(ci"name" -> "field1")), + `Content-Type`(MediaType.text.plain))) + assert(confirmedError.isInstanceOf[Left[_, _]]) + assert( + confirmedError.left + .getOrElse(throw new Exception) + .isInstanceOf[MalformedMessageBodyFailure]) + } } } @@ -643,15 +720,17 @@ class MultipartParserSuite extends Http4sSuite { val input = ruinDelims(unprocessedInput) - for { - // This should be false until we drain the whole input. - ref <- Ref[IO].of(false) - trackedInput = unspool(input, chunkSize) ++ Stream.eval(ref.set(true)).drain + mkMultipartPipe(boundary).use { multipartPipe => + for { + // This should be false until we drain the whole input. + ref <- Ref[IO].of(false) + trackedInput = unspool(input, chunkSize) ++ Stream.eval(ref.set(true)).drain - _ <- trackedInput.through(multipartPipe(boundary)).compile.drain + _ <- trackedInput.through(multipartPipe).compile.drain - reachedTheEnd <- ref.get - } yield assert(reachedTheEnd) + reachedTheEnd <- ref.get + } yield assert(reachedTheEnd) + } } List(1, 2, 3, 5, 8, 13, 21, 987).foreach(drainEpilogue) @@ -669,9 +748,14 @@ class MultipartParserSuite extends Http4sSuite { |catch me if you can!""".stripMargin val input = ruinDelims(unprocessedInput) - val results = unspool(input).through(multipartPipe(boundary)) + val mkResults = + mkMultipartPipe(boundary).map( + _(unspool(input)) + ) - results.compile.toVector.intercept[MalformedMessageBodyFailure] + mkResults.use { results => + results.compile.toVector.intercept[MalformedMessageBodyFailure] + } } } @@ -681,11 +765,22 @@ class MultipartParserSuite extends Http4sSuite { MultipartParser.parseStreamed[IO], MultipartParser.parseToPartsStream[IO](_)) - multipartParserTests( - "mixed file parser", - MultipartParser.parseStreamedFile[IO](_, Http4sSuite.TestBlocker), - MultipartParser.parseStreamedFile[IO](_, Http4sSuite.TestBlocker, _), - MultipartParser.parseToPartsStreamedFile[IO](_, Http4sSuite.TestBlocker) + { + @nowarn("cat=deprecation") + val testDeprecated = multipartParserTests( + "mixed file parser", + MultipartParser.parseStreamedFile[IO](_), + MultipartParser.parseStreamedFile[IO](_, _), + MultipartParser.parseToPartsStreamedFile[IO](_) + ) + testDeprecated + } + + multipartParserResourceTests( + "supervised file parser", + b => Supervisor[IO].map(MultipartParser.parseSupervisedFile[IO](_, b)), + (b, limit) => Supervisor[IO].map(MultipartParser.parseSupervisedFile[IO](_, b, limit)), + b => Supervisor[IO].map(MultipartParser.parseToPartsSupervisedFile[IO](_, b)) ) test("Multipart mixed file parser: truncate parts when limit set") { @@ -705,9 +800,9 @@ class MultipartParserSuite extends Http4sSuite { val input = ruinDelims(unprocessedInput) val boundaryTest = Boundary("RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0") + @nowarn("cat=deprecation") val results = - unspool(input).through( - MultipartParser.parseStreamedFile[IO](boundaryTest, Http4sSuite.TestBlocker, maxParts = 1)) + unspool(input).through(MultipartParser.parseStreamedFile[IO](boundaryTest, maxParts = 1)) results.compile.last .map(_.get) @@ -739,16 +834,122 @@ class MultipartParserSuite extends Http4sSuite { val input = ruinDelims(unprocessedInput) val boundaryTest = Boundary("RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0") + @nowarn("cat=deprecation") val results = unspool(input).through( MultipartParser - .parseStreamedFile[IO]( - boundaryTest, - Http4sSuite.TestBlocker, - maxParts = 1, - failOnLimit = true)) + .parseStreamedFile[IO](boundaryTest, maxParts = 1, failOnLimit = true)) results.compile.last .map(_.get) .intercept[MalformedMessageBodyFailure] } + + test("Multipart supervised file parser: truncate parts when limit set") { + val unprocessedInput = + """ + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0 + |Content-Disposition: form-data; name="field1" + |Content-Type: text/plain + | + |Text_Field_1 + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0 + |Content-Disposition: form-data; name="field2" + | + |Text_Field_2 + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0--""".stripMargin + + val input = ruinDelims(unprocessedInput) + + val boundaryTest = Boundary("RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0") + val mkResults = + Supervisor[IO].map { supervisor => + unspool(input).through( + MultipartParser.parseSupervisedFile[IO](supervisor, boundaryTest, maxParts = 1)) + } + + mkResults.use { results => + results.compile.last + .map(_.get) + .map(_.parts.foldLeft(List.empty[Headers])((l, r) => l ::: List(r.headers))) + .assertEquals( + List( + Headers( + `Content-Disposition`("form-data", Map(ci"name" -> "field1")), + `Content-Type`(MediaType.text.plain) + ) + )) + } + } + + test( + "Multipart supervised file parser: fail parsing when parts limit exceeded if set fail as option") { + val unprocessedInput = + """ + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0 + |Content-Disposition: form-data; name="field1" + |Content-Type: text/plain + | + |Text_Field_1 + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0 + |Content-Disposition: form-data; name="field2" + | + |Text_Field_2 + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0--""".stripMargin + + val input = ruinDelims(unprocessedInput) + + val boundaryTest = Boundary("RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0") + val mkResults = + Supervisor[IO].map { supervisor => + unspool(input).through( + MultipartParser + .parseSupervisedFile[IO](supervisor, boundaryTest, maxParts = 1, failOnLimit = true)) + } + + mkResults.map { results => + results.compile.last + .map(_.get) + .intercept[MalformedMessageBodyFailure] + } + } + + test("Multipart supervised file parser: dispose of the files when the resource is released") { + val unprocessedInput = + """ + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0 + |Content-Disposition: form-data; name="field1" + |Content-Type: text/plain + | + |Text_Field_1 + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0 + |Content-Disposition: form-data; name="field2" + | + |Text_Field_2 + |--RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0--""".stripMargin + + val input = ruinDelims(unprocessedInput) + + val boundaryTest = Boundary("RU(_9F(PcJK5+JMOPCAF6Aj4iSXvpJkWy):6s)YU0") + val mkResults = + Supervisor[IO].map { supervisor => + unspool(input).through( + MultipartParser + // Make sure the data will get written to files + .parseSupervisedFile[IO](supervisor, boundaryTest, maxSizeBeforeWrite = 8)) + } + + // This is roundabout, but there's no way to test this directly without stubbing `Files` somehow. + mkResults + .use { results => + results.compile.last + .map(_.get) + } + .flatMap { stale => + // At this point, the supervisor was released, so the files have to have been deleted. + stale.parts.traverse_( + _.body.compile.drain + .intercept[NoSuchFileException] + ) + } + } } diff --git a/tests/src/test/scala/org/http4s/multipart/MultipartSuite.scala b/tests/src/test/scala/org/http4s/multipart/MultipartSuite.scala index 0eaf2ca4d7d..7b9e7edaf32 100644 --- a/tests/src/test/scala/org/http4s/multipart/MultipartSuite.scala +++ b/tests/src/test/scala/org/http4s/multipart/MultipartSuite.scala @@ -21,15 +21,15 @@ import cats._ import cats.effect._ import cats.syntax.all._ import fs2._ + import java.io.File import org.http4s.headers._ import org.http4s.syntax.literals._ import org.http4s.EntityEncoder._ import org.typelevel.ci._ +import scala.annotation.nowarn class MultipartSuite extends Http4sSuite { - implicit val contextShift: ContextShift[IO] = Http4sSuite.TestContextShift - val url = uri"https://example.com/path/to/some/where" implicit def partIOEq: Eq[Part[IO]] = @@ -49,7 +49,9 @@ class MultipartSuite extends Http4sSuite { a.parts === b.parts } - def multipartSpec(name: String)(implicit E: EntityDecoder[IO, Multipart[IO]]) = { + def multipartSpec(name: String)( + mkDecoder: Resource[IO, EntityDecoder[IO, Multipart[IO]]] + ) = { { test(s"Multipart form data $name should be encoded and decoded with content types") { val field1 = @@ -60,10 +62,13 @@ class MultipartSuite extends Http4sSuite { val body = entity.body val request = Request(method = Method.POST, uri = url, body = body, headers = multipart.headers) - val decoded = EntityDecoder[IO, Multipart[IO]].decode(request, true) - val result = decoded.value - assertIOBoolean(result.map(_ === Right(multipart))) + mkDecoder.use { decoder => + val decoded = decoder.decode(request, true) + val result = decoded.value + + assertIOBoolean(result.map(_ === Right(multipart))) + } } test(s"Multipart form data $name should be encoded and decoded without content types") { @@ -74,10 +79,13 @@ class MultipartSuite extends Http4sSuite { val body = entity.body val request = Request(method = Method.POST, uri = url, body = body, headers = multipart.headers) - val decoded = EntityDecoder[IO, Multipart[IO]].decode(request, true) - val result = decoded.value - assertIOBoolean(result.map(_ === Right(multipart))) + mkDecoder.use { decoder => + val decoded = decoder.decode(request, true) + val result = decoded.value + + assertIOBoolean(result.map(_ === Right(multipart))) + } } test(s"Multipart form data $name should encoded and decoded with binary data") { @@ -85,7 +93,7 @@ class MultipartSuite extends Http4sSuite { val field1 = Part.formData[IO]("field1", "Text_Field_1") val field2 = Part - .fileData[IO]("image", file, Http4sSuite.TestBlocker, `Content-Type`(MediaType.image.png)) + .fileData[IO]("image", file, `Content-Type`(MediaType.image.png)) val multipart = Multipart[IO](Vector(field1, field2)) @@ -94,10 +102,12 @@ class MultipartSuite extends Http4sSuite { val request = Request(method = Method.POST, uri = url, body = body, headers = multipart.headers) - val decoded = EntityDecoder[IO, Multipart[IO]].decode(request, true) - val result = decoded.value + mkDecoder.use { decoder => + val decoded = decoder.decode(request, true) + val result = decoded.value - assertIOBoolean(result.map(_ === Right(multipart))) + assertIOBoolean(result.map(_ === Right(multipart))) + } } test(s"Multipart form data $name should be decoded and encode with content types") { @@ -125,13 +135,15 @@ Content-Type: application/pdf val request = Request[IO]( method = Method.POST, uri = url, - body = Stream.emit(body).covary[IO].through(text.utf8Encode), + body = Stream.emit(body).covary[IO].through(text.utf8.encode), headers = header) - val decoded = EntityDecoder[IO, Multipart[IO]].decode(request, true) - val result = decoded.value.map(_.isRight) + mkDecoder.use { decoder => + val decoded = decoder.decode(request, true) + val result = decoded.value.map(_.isRight) - result.assertEquals(true) + result.assertEquals(true) + } } test(s"Multipart form data $name should be decoded and encoded without content types") { @@ -153,12 +165,15 @@ I am a big moose val request = Request[IO]( method = Method.POST, uri = url, - body = Stream.emit(body).through(text.utf8Encode), + body = Stream.emit(body).through(text.utf8.encode), headers = header) - val decoded = EntityDecoder[IO, Multipart[IO]].decode(request, true) - val result = decoded.value.map(_.isRight) - result.assertEquals(true) + mkDecoder.use { decoder => + val decoded = decoder.decode(request, true) + val result = decoded.value.map(_.isRight) + + result.assertEquals(true) + } } test(s"Multipart form data $name should extract name properly if it is present") { @@ -188,8 +203,15 @@ I am a big moose } } - multipartSpec("with default decoder")(implicitly) - multipartSpec("with mixed decoder")(EntityDecoder.mixedMultipart[IO](Http4sSuite.TestBlocker)) + multipartSpec("with default decoder")(Resource.pure(implicitly)) + + { + @nowarn("cat=deprecation") + val testDeprecated = + multipartSpec("with mixed decoder")(Resource.pure(EntityDecoder.mixedMultipart[IO]())) + testDeprecated + } + multipartSpec("with mixed resource decoder")(EntityDecoder.mixedMultipartResource[IO]()) def testPart[F[_]] = Part[F](Headers.empty, EmptyBody) diff --git a/tests/src/test/scala/org/http4s/parser/SetCookieHeaderSuite.scala b/tests/src/test/scala/org/http4s/parser/SetCookieHeaderSuite.scala deleted file mode 100644 index 25935ab6a60..00000000000 --- a/tests/src/test/scala/org/http4s/parser/SetCookieHeaderSuite.scala +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 parser - -import cats.syntax.all._ -import org.http4s.headers.`Set-Cookie` - -class SetCookieHeaderSuite extends munit.FunSuite { - def parse(value: String) = `Set-Cookie`.parse(value).valueOr(throw _) - - test("Set-Cookie parser should parse a set cookie") { - val cookiestr = - "myname=\"foo\"; Domain=example.com; Max-Age=1; Path=value; SameSite=Strict; Secure; HttpOnly" - val c = parse(cookiestr).cookie - assertEquals(c.name, "myname") - assertEquals(c.domain, Some("example.com")) - assertEquals(c.content, """"foo"""") - assertEquals(c.maxAge, Some(1L)) - assertEquals(c.path, Some("value")) - assertEquals(c.sameSite, Some(SameSite.Strict)) - assertEquals(c.secure, true) - assertEquals(c.httpOnly, true) - } - - test("Set-Cookie parser should default to None") { - val cookiestr = "myname=\"foo\"; Domain=value; Max-Age=1; Path=value" - val c = parse(cookiestr).cookie - assertEquals(c.sameSite, None) - } - - test("Set-Cookie parser should parse a set cookie with lowercase attributes") { - val cookiestr = - "myname=\"foo\"; domain=example.com; max-age=1; path=value; samesite=strict; secure; httponly" - val c = parse(cookiestr).cookie - assertEquals(c.name, "myname") - assertEquals(c.domain, Some("example.com")) - assertEquals(c.content, """"foo"""") - assertEquals(c.maxAge, Some(1L)) - assertEquals(c.path, Some("value")) - assertEquals(c.sameSite, Some(SameSite.Strict)) - assertEquals(c.secure, true) - assertEquals(c.httpOnly, true) - } - - test("Set-Cookie parser should parse with a domain with a leading dot") { - val cookiestr = "myname=\"foo\"; Domain=.example.com" - val c = parse(cookiestr).cookie - assertEquals(c.domain, Some(".example.com")) - } - - test("Set-Cookie parser should parse with an extension") { - val cookiestr = "myname=\"foo\"; http4s=fun" - val c = parse(cookiestr).cookie - assertEquals(c.extension, Some("http4s=fun")) - } - - test("Set-Cookie parser should parse with two extensions") { - val cookiestr = "myname=\"foo\"; http4s=fun; rfc6265=not-fun" - val c = parse(cookiestr).cookie - assertEquals(c.extension, Some("http4s=fun; rfc6265=not-fun")) - } - - test("Set-Cookie parser should parse with two extensions around a common attribute") { - val cookiestr = "myname=\"foo\"; http4s=fun; Domain=example.com; rfc6265=not-fun" - val c = parse(cookiestr).cookie - assertEquals(c.domain, Some("example.com")) - assertEquals(c.extension, Some("http4s=fun; rfc6265=not-fun")) - } -} diff --git a/tomcat-server/src/main/scala/org/http4s/tomcat/server/TomcatBuilder.scala b/tomcat-server/src/main/scala/org/http4s/tomcat/server/TomcatBuilder.scala index 6ece02c99c5..73ade6bbc9d 100644 --- a/tomcat-server/src/main/scala/org/http4s/tomcat/server/TomcatBuilder.scala +++ b/tomcat-server/src/main/scala/org/http4s/tomcat/server/TomcatBuilder.scala @@ -19,6 +19,8 @@ package tomcat package server import cats.effect._ +import cats.effect.std.Dispatcher + import java.net.InetSocketAddress import java.util import java.util.concurrent.Executor @@ -44,6 +46,7 @@ import org.http4s.servlet.{AsyncHttp4sServlet, ServletContainer, ServletIo} import org.http4s.syntax.all._ import org.http4s.tomcat.server.TomcatBuilder._ import org.log4s.getLogger + import scala.collection.immutable import scala.concurrent.duration._ @@ -58,7 +61,7 @@ sealed class TomcatBuilder[F[_]] private ( private val serviceErrorHandler: ServiceErrorHandler[F], banner: immutable.Seq[String], classloader: Option[ClassLoader] -)(implicit protected val F: ConcurrentEffect[F]) +)(implicit protected val F: Async[F]) extends ServletContainer[F] with ServerBuilder[F] { type Self = TomcatBuilder[F] @@ -117,7 +120,7 @@ sealed class TomcatBuilder[F[_]] private ( servlet: HttpServlet, urlMapping: String, name: Option[String] = None): Self = - copy(mounts = mounts :+ Mount[F] { (ctx, index, _) => + copy(mounts = mounts :+ Mount[F] { (ctx, index, _, _) => val servletName = name.getOrElse(s"servlet-$index") val wrapper = Tomcat.addServlet(ctx, servletName, servlet) wrapper.addMapping(urlMapping) @@ -129,7 +132,7 @@ sealed class TomcatBuilder[F[_]] private ( urlMapping: String, name: Option[String], dispatches: util.EnumSet[DispatcherType]): Self = - copy(mounts = mounts :+ Mount[F] { (ctx, index, _) => + copy(mounts = mounts :+ Mount[F] { (ctx, index, _, _) => val filterName = name.getOrElse(s"filter-$index") val filterDef = new FilterDef @@ -152,12 +155,13 @@ sealed class TomcatBuilder[F[_]] private ( mountHttpApp(service.orNotFound, prefix) def mountHttpApp(service: HttpApp[F], prefix: String): Self = - copy(mounts = mounts :+ Mount[F] { (ctx, index, builder) => + copy(mounts = mounts :+ Mount[F] { (ctx, index, builder, dispatcher) => val servlet = new AsyncHttp4sServlet( service = service, asyncTimeout = builder.asyncTimeout, servletIo = builder.servletIo, - serviceErrorHandler = builder.serviceErrorHandler + serviceErrorHandler = builder.serviceErrorHandler, + dispatcher = dispatcher ) val wrapper = Tomcat.addServlet(ctx, s"servlet-$index", servlet) wrapper.addMapping(ServletContainer.prefixMapping(prefix)) @@ -188,68 +192,69 @@ sealed class TomcatBuilder[F[_]] private ( copy(classloader = Some(classloader)) override def resource: Resource[F, Server] = - Resource(F.delay { - val tomcat = new Tomcat - val cl = classloader.getOrElse(getClass.getClassLoader) - val docBase = cl.getResource("") match { - case null => null - case resource => resource.getPath - } - tomcat.addContext("", docBase) - - val conn = tomcat.getConnector() - sslConfig.configureConnector(conn) - - conn.setProperty("address", socketAddress.getHostString) - conn.setPort(socketAddress.getPort) - conn.setProperty( - "connection_pool_timeout", - (if (idleTimeout.isFinite) idleTimeout.toSeconds.toInt else 0).toString) - - externalExecutor.foreach { ee => - conn.getProtocolHandler match { - case p: AbstractProtocol[_] => - p.setExecutor(ee) - case _ => - logger.warn("Could not set external executor. Defaulting to internal") + Dispatcher[F].flatMap(dispatcher => + Resource(F.blocking { + val tomcat = new Tomcat + val cl = classloader.getOrElse(getClass.getClassLoader) + val docBase = cl.getResource("") match { + case null => null + case resource => resource.getPath + } + tomcat.addContext("", docBase) + + val conn = tomcat.getConnector() + sslConfig.configureConnector(conn) + + conn.setProperty("address", socketAddress.getHostString) + conn.setPort(socketAddress.getPort) + conn.setProperty( + "connection_pool_timeout", + (if (idleTimeout.isFinite) idleTimeout.toSeconds.toInt else 0).toString) + + externalExecutor.foreach { ee => + conn.getProtocolHandler match { + case p: AbstractProtocol[_] => + p.setExecutor(ee) + case _ => + logger.warn("Could not set external executor. Defaulting to internal") + } } - } - val rootContext = tomcat.getHost.findChild("").asInstanceOf[Context] - for ((mount, i) <- mounts.zipWithIndex) - mount.f(rootContext, i, this) + val rootContext = tomcat.getHost.findChild("").asInstanceOf[Context] + for ((mount, i) <- mounts.zipWithIndex) + mount.f(rootContext, i, this, dispatcher) - tomcat.start() + tomcat.start() - val server = new Server { - lazy val address: InetSocketAddress = { - val host = socketAddress.getHostString - val port = tomcat.getConnector.getLocalPort - new InetSocketAddress(host, port) - } + val server = new Server { + lazy val address: InetSocketAddress = { + val host = socketAddress.getHostString + val port = tomcat.getConnector.getLocalPort + new InetSocketAddress(host, port) + } - lazy val isSecure: Boolean = sslConfig.isSecure - } + lazy val isSecure: Boolean = sslConfig.isSecure + } - val shutdown = F.delay { - tomcat.stop() - tomcat.destroy() - } + val shutdown = F.blocking { + tomcat.stop() + tomcat.destroy() + } - banner.foreach(logger.info(_)) - val tomcatVersion = ServerInfo.getServerInfo.split("/") match { - case Array(_, version) => version - case _ => ServerInfo.getServerInfo // well, we tried - } - logger.info( - s"http4s v${BuildInfo.version} on Tomcat v${tomcatVersion} started at ${server.baseUri}") + banner.foreach(logger.info(_)) + val tomcatVersion = ServerInfo.getServerInfo.split("/") match { + case Array(_, version) => version + case _ => ServerInfo.getServerInfo // well, we tried + } + logger.info( + s"http4s v${BuildInfo.version} on Tomcat v${tomcatVersion} started at ${server.baseUri}") - server -> shutdown - }) + server -> shutdown + })) } object TomcatBuilder { - def apply[F[_]: ConcurrentEffect]: TomcatBuilder[F] = + def apply[F[_]: Async]: TomcatBuilder[F] = new TomcatBuilder[F]( socketAddress = defaults.IPv4SocketAddress, externalExecutor = None, @@ -310,4 +315,4 @@ object TomcatBuilder { } } -private final case class Mount[F[_]](f: (Context, Int, TomcatBuilder[F]) => Unit) +private final case class Mount[F[_]](f: (Context, Int, TomcatBuilder[F], Dispatcher[F]) => Unit) diff --git a/tomcat-server/src/test/scala/org/http4s/tomcat/server/TomcatServerSuite.scala b/tomcat-server/src/test/scala/org/http4s/tomcat/server/TomcatServerSuite.scala index 61b47a0898b..43774a1c19c 100644 --- a/tomcat-server/src/test/scala/org/http4s/tomcat/server/TomcatServerSuite.scala +++ b/tomcat-server/src/test/scala/org/http4s/tomcat/server/TomcatServerSuite.scala @@ -18,8 +18,7 @@ package org.http4s package tomcat package server -import cats.effect.{ContextShift, IO, Timer} -import cats.syntax.all._ +import cats.effect.IO import java.io.IOException import java.net.{HttpURLConnection, URL} import java.nio.charset.StandardCharsets @@ -31,7 +30,6 @@ import scala.io.Source import java.util.logging.LogManager class TomcatServerSuite extends Http4sSuite { - implicit val contextShift: ContextShift[IO] = Http4sSuite.TestContextShift override def beforeEach(context: BeforeEach): Unit = { // Prevents us from loading jar and war URLs, but lets us @@ -63,7 +61,7 @@ class TomcatServerSuite extends Http4sSuite { IO.never case GET -> Root / "slow" => - implicitly[Timer[IO]].sleep(50.millis) *> Ok("slow") + IO.sleep(50.millis) *> Ok("slow") }, "/" ) @@ -72,15 +70,14 @@ class TomcatServerSuite extends Http4sSuite { val tomcatServer = ResourceFixture[Server](serverR) def get(server: Server, path: String): IO[String] = - testBlocker.blockOn( - IO( - Source - .fromURL(new URL(s"http://127.0.0.1:${server.address.getPort}$path")) - .getLines() - .mkString)) + IO.blocking( + Source + .fromURL(new URL(s"http://127.0.0.1:${server.address.getPort}$path")) + .getLines() + .mkString) def post(server: Server, path: String, body: String): IO[String] = - testBlocker.blockOn(IO { + IO.blocking { val url = new URL(s"http://127.0.0.1:${server.address.getPort}$path") val conn = url.openConnection().asInstanceOf[HttpURLConnection] val bytes = body.getBytes(StandardCharsets.UTF_8) @@ -92,7 +89,7 @@ class TomcatServerSuite extends Http4sSuite { .fromInputStream(conn.getInputStream, StandardCharsets.UTF_8.name) .getLines() .mkString - }) + } tomcatServer.test("server should route requests on the service executor".flaky) { server => val prefix: String = "http4s-suite-" diff --git a/website/src/hugo/content/adopters.md b/website/src/hugo/content/adopters.md index 5bb97f361ad..99c5ecef569 100644 --- a/website/src/hugo/content/adopters.md +++ b/website/src/hugo/content/adopters.md @@ -21,6 +21,9 @@ title: Adopters [Formation](https://www.formation.ai/) : Uses the client internally +[HiFi](https://hi.fi/) +: Uses http4s for internal web services + [看录取 Kanluqu](https://www.kanluqu.com) : College application resources for Chinese high school students, built entirely upon the Typelevel stack. @@ -36,9 +39,18 @@ title: Adopters [Quantiply](https://www.quantiply.com) : Uses http4s to power the critical data APIs. +[SecurityScorecard](https://securityscorecard.io) +: Uses http4s to power its critical data pipeline. + [Verizon](http://www.verizon.com) : Uses http4s extensively in its internal services and [open source projects](http://verizon.github.io). +[Wegtam GmbH](https://www.wegtam.com) +: Uses http4s to implement service and microservice architectures as well as web applications for customers. + +[Wolt](https://wolt.com/) +: Uses http4s for some API services. + ## Libraries [Avias](https://github.com/fiadliel/avias) @@ -91,6 +103,9 @@ title: Adopters [Docspell](https://github.com/eikek/docspell) : A personal document (pdf) organizer +[fink](https://github.com/dozed/fink-http4s) +: A simple Scala-based content management system + [fleet-buddy](https://github.com/reactormonk/fleet-buddy) : Eve Online fleet buddy based on the CREST API @@ -140,6 +155,12 @@ G8 templates provide a fast way to get started with SBT projects by just running [http4s.g8](https://github.com/http4s/http4s.g8) : Bootstrap Http4s services +[http4s-app.g8](https://codeberg.org/wegtam/http4s-app.g8) +: Bootstrap web apps based on Http4s including database migrations and on Typelevel Stack + +[http4s-tapir.g8](https://codeberg.org/wegtam/http4s-tapir.g8) +: Bootstrap HTTP services using Http4s and sttp tapir (Typed API descRiptions) + [typelevel-stack.g8](https://github.com/gvolpe/typelevel-stack.g8) : Typelevel Stack (Http4s / Doobie / Circe / Cats Effect / Fs2) diff --git a/website/src/hugo/content/changelog.md b/website/src/hugo/content/changelog.md index dffda44b1d8..15c61590bd9 100644 --- a/website/src/hugo/content/changelog.md +++ b/website/src/hugo/content/changelog.md @@ -8,6 +8,16 @@ Maintenance branches are merged before each new release. This change log is ordered chronologically, so each release contains all changes described below it. +# v0.23.3 (2021-09-02) + +This is binary compatible with v0.23.3. It includes the fixes in v0.22.2. + +## http4s-ember-server + +### Bugfixes + +* [#5138](https://github.com/http4s/http4s/pull/5138): Correctly populate the `SecureSession` response attribute. + # v0.22.4 (2021-09-02) This is binary compatibile with v0.22.3. It includes the CORS bugfix in v0.21.28. @@ -34,11 +44,42 @@ This release is binary compatible with 0.21.x. * [#5144](https://github.com/http4s/http4s/pull/5144): In the `CORS` middleware, respond to preflight `OPTIONS` requests with a 200 status. It was previously passing through to the wrapped `Http`, most of which won't respond to `OPTIONS`. The breaking change is that the constraint is promoted from `Functor` to `Applicative`. The `Functor` version is left for binary compatibility with a runtime warning. -# v0.22.3 (unreleased) +# v0.23.2 (2021-09-01) + +This release includes a security patch to [GHSA-52cf-226f-rhr6](https://github.com/http4s/http4s/security/advisories/GHSA-52cf-226f-rhr6), along with all changes in v0.22.3. + +This release is binary compatible with the 0.23 series. + +## http4s-core + +### Enhancements + +* [#5085](https://github.com/http4s/http4s/pull/5085): Make `EntityEncoder`s for `File`, `Path`, and `InputStream` implicit. Since 0.23, they no longer require an explicit `Blocker` parameter, using Cats-Effect 3's runtime instead. + +## http4s-blaze-server + +### Bug fixes + +* [#5118](https://github.com/http4s/http4s/pull/5118): Don't block the `TickWheelExecutor` on cancellation. In-flight responses are canceled when a connection shuts down. If the response cancellation hangs, it blocks the `TickWheelScheduler` thread. When this thread blocks, subsequent scheduled events are not processed, and memory leaks with each newly scheduled event. + +### Enhancements + +* [#4782](https://github.com/http4s/http4s/pull/4782): Use `Async[F].executionContext` as a default `ExecutionContext` in `BlazeServerBuilder`. + +## http4s-ember-server + +* [#5106](https://github.com/http4s/http4s/pull/5106): Demote noisy `WebSocket connection terminated with exception` message to trace-level logging on broken pipes. This relies on exception message parsing and may not work well in all locales. + +## Dependency updates + +* cats-effect-3.2.5 +* fs2-3.1.1 + +# v0.22.3 (2021-09-01) This release includes a security patch to [GHSA-52cf-226f-rhr6](https://github.com/http4s/http4s/security/advisories/GHSA-52cf-226f-rhr6), along with all changes in 0.21.26 and 0.21.27. -Binary compatible with the 0.22.2 series, with the exception of static forwarders in `HttpApp.apply`, `HttpApp.local`. Unless you are calling `HttpApp` from a language other than Scala, you are not affected. +Binary compatible with 0.22.2 series, with the exception of static forwarders in `HttpApp.apply`, `HttpApp.local`. Unless you are calling `HttpApp` from a language other than Scala, you are not affected. ## http4s-core @@ -166,6 +207,16 @@ The 0.21 series is no longer actively maintained by the team, but we'll continue * Less than 2 attempts have been made * Ember detects that the connection was closed without reading any bytes +# v0.23.1 (2021-08-06) + +Includes all changes through v0.22.2. + +### Dependency updates + +* cats-effect-3.2.2 +* fs2-3.1.0 +* vault-3.0.4 + # v0.22.2 (2021-08-06) ## http4s-core @@ -184,6 +235,39 @@ The 0.21 series is no longer actively maintained by the team, but we'll continue * cats-effect-2.5.3 * tomcat-9.0.52 +# v0.23.0 (2021-07-30) + +This is the first production release with Cats-Effect 3 support. All subsequent 0.23.x releases will be binary compatible with this. + +Includes all changes through v0.22.1. + +## http4s-core + +### Breaking changes + +* [#4997](https://github.com/http4s/http4s/pull/4997): Refresh MimeDB from the IANA registry. It shuffles some constants in ways that offend MiMa, but you almost certainly won't notice. + +### Enhancements + +* [#4915](https://github.com/http4s/http4s/pull/4915): Add file-based multipart decoder with better resource handling. This deprecates the priod `mixedMultipart` decoder in favor of a `mixedMultipartResource`, which cleans up temporary storage on release of the resource. Please see the scaladoc for a usage example. + +## Various modules + +### Breaking changes + +* [#4998](https://github.com/http4s/http4s/pull/4998): Removes everything deprecated since 0.20.0, over 24 months and three breaking releases ago. See the pull request for a comprehensive list. + +### Refactoring + +* [#4986](https://github.com/http4s/http4s/pull/4986): Light refactoring of fs2 pipes in Ember and Blaze backends. Should not be visible. + +## Dependency updates + +* cats-effect-3.2.0 +* fs2-3.0.6 +* jawn-fs2-2.1.0 +* keypool-0.4.6 + # v0.22.1 (2021-07-30) ## http4s-core @@ -290,6 +374,36 @@ Includes all changes from v0.21.25. * slf4j-1.7.31 * tomcat-9.0.50 +# v1.0.0-M23 (2021-05-26) + +Functionally equivalent to v0.23.0-RC1. Keeps the 1.0 milestones current as we continue our roadmap. Includes the [vulnerability fix](https://github.com/http4s/http4s-ghsa-6h7w-fc84-x7p6) to `StaticFile.fromUrl`. + +# v0.23.0-RC1 (2021-05-26) + +Includes the changes of v0.22.0-RC1, including the [vulnerability fix](https://github.com/http4s/http4s-ghsa-6h7w-fc84-x7p6) to `StaticFile.fromUrl`. + +## http4s-core + +### Breaking changes + +* [#4884](https://github.com/http4s/http4s/pull/4884): Use `Monad` instead of `Defer` constraints on `HttpApp`, `HttpRoutes`, `AuthedRoutes`, `ContextRoutes`, and related syntax. This avoids diverging implicits when only a `Concurrent` constraint is available in Cats-Effect-3. + +### Noteworthy refactoring + +* [#4773](https://github.com/http4s/http4s/pull/4787): Refactor the internals of the `Multipart` parser. + +## http4s-ember-client + +### Noteworthy refactoring + +* [#4882](https://github.com/http4s/http4s/pull/4882): Use `Network` instead of `Network.forAsync` to get the socket group. + +## http4s-ember-server + +### Noteworthy refactoring + +* [#4882](https://github.com/http4s/http4s/pull/4882): Use `Network` instead of `Network.forAsync` to get the socket group. + # v0.22.0-RC1 (2021-05-26) Includes the changes of 0.21.24, including the [vulnerability fix](https://github.com/http4s/http4s-ghsa-6h7w-fc84-x7p6) to `StaticFile.fromUrl`. @@ -341,9 +455,34 @@ Contains a vulnerability fix for `StaticFile.fromUrl`. * blaze-0.14.17 +# v1.0.0-M22 (2021-05-21) + +Functionally equivalent to v0.23.0-M1. Keeps the 1.0 milestones current as we continue our roadmap. + +# v0.23.0-M1 (2021-05-21) + +We are opening an 0.23 series to offer full support for Scala 3 and Cats-Effect 3 while giving ourselves a bit more time to finish our more ambitious goals for 1.0. We will release v0.23.0 with production support as soon as circe-0.14 is out. + +This release picks up from v1.0.0-M21 with its Cats-Effect 3 support, and includes all improvements from v0.22.0-M8. + +## Documentation + +* [#4845](https://github.com/http4s/http4s/pull/4845): Mention `Client.fromHttpApp` + +## Dependency updates + +* cats-effect-3.1.0 +* fs2-3.0.4 +* ip4s-3.0.2 +* jawn-fs2-2.0.2 +* keypool-0.4.5 +* log4cats-2.1.1 +* scalacheck-effect-1.0.2 +* vault-3.0.3 + # v0.22.0-M8 (2021-05-21) -Includes the changes of v0.21.23. This is the first release with support for Scala 3.0.0. We intend to make this a production release as soon as circe-0.14 is out. +Includes the changes of v0.21.23. This is the first release with support for Scala 3.0.0. We will release v0.22.0 with production support as circe-0.14 is out. There are several package renames in the backends. To help, we've provided a Scalafix: @@ -514,6 +653,18 @@ This is the final planned release in the 0.21 series. Bugfixes and community su * scodec-bits-1.1.27 * tomcat-9.0.46 +# v1.0.0-M21 (2021-04-10) + +Contains all the changes of v0.22.0-M7. + +## Dependency updates + +* cats-effect-3.0.1 +* jawn-fs2-2.0.1 +* keypool-0.4.1 +* log4cats-2.0.1 +* vault-3.0.1 + # v0.22.0-M7 (2021-04-10) Contains all the changes of v0.21.22. @@ -565,6 +716,19 @@ Contains all the changes of v0.21.22. * tomcat-9.0.45 * twirl-1.5.1 +# v1.0.0-M20 (2021-03-29) + +Includes all the changes of v0.21.21 and v0.22.0-M6. + +## Dependency updates + +* cats-effect-3.0.0 +* fs2-3.0.0 +* jawn-fs2-2.0.0 +* keypool-0.4.0 +* log4cats-2.0.0 +* vault-3.0.0 + # v0.22.0-M6 (2021-03-29) Includes all the changes of v0.21.21. @@ -660,6 +824,12 @@ Includes all the changes of v0.21.21. * scalatags-0.9.4 * tomcat-9.0.44 +# v1.0.0-M19 (2021-03-03) + +This is the first 1.0 milestone with Scala 3 support. Scala 3.0.0-RC1 is supported for all modules except http4s-boopickle, http4s-scalatags, and http4s-twirl. + +This release contains all the changes of v0.22.0-M5. + # v0.22.0-M5 (2021-03-03) This is the first release with Scala 3 support. Scala 3.0.0-RC1 is supported for all modules except http4s-boopickle, http4s-scalatags, and http4s-twirl. @@ -695,8 +865,52 @@ See [#4415](https://github.com/http4s/http4s/pull/4415), [#4526](https://github. * [#4579](https://github.com/http4s/http4s/pull/4579): Regenerate MimeDB from the IANA registry +# v1.0.0-M18 (2021-03-02) + +Includes changes from v0.22.0-M4. + +## http4s-core + +### Breaking changes + +* [#4516](https://github.com/http4s/http4s/pull/4516): Replace `Defer: Applicative` constraint with `Monad` in `HttpRoutes.of` and `ContextRoutes.of`. This should be source compatible for nearly all users. Users who can't abide this constraint can use `.strict`, at the cost of efficiency in combinining routes. + +### Enhancements + +* [#4351](https://github.com/http4s/http4s/pull/4351): Optimize multipart parser for the fact that pull can't return empty chunks +* [#4485](https://github.com/http4s/http4s/pull/4485): Drop dependency to `cats-effect-std`. There are no hard dependencies on `cats.effect.IO` outside the tests. + +## http4s-blaze-core + +### Enhancements + +* [#4425](https://github.com/http4s/http4s/pull/4425): Optimize entity body writer + +## http4s-ember-server + +### Breaking changes + +* [#4471](https://github.com/http4s/http4s/pull/4471): `EmberServerBuilder` takes an ip4s `Option[Host]` and `Port` in its config instead of `String` and `Int`. +* [#4515](https://github.com/http4s/http4s/pull/4515): Temporarily revert the graceful shutdown until a new version of FS2 suports it. + +### Dependency updates + +* cats-effect-3.0.0-RC2 +* fs2-3.0.0-M9 +* jawn-fs2-2.0.0-RC3 +* ip4s-3.0.0-RC2 +* keypool-0.4.0-RC2 +* log4cats-2.0.0-RC1 +* vault-3.0.0-RC2 + +~~# v1.0.0-M17 (2021-03-02)~~ + +Missed the forward merges from 0.22.0-M4. Proceed directly to 1.0.0-M18. + # v0.22.0-M4 (2021-03-02) +Includes changes from v0.21.19 and v0.21.20. + ## http4s-core ### Breaking changes @@ -861,6 +1075,16 @@ See [#4415](https://github.com/http4s/http4s/pull/4415), [#4526](https://github. * okio-2.9.0 * tomcat-9.0.43 +# v1.0.0-M16 (2021-02-02) + +Inherits the fixes of v0.21.18 + +~~# v1.0.0-M15 (2021-02-02)~~ + +~~Build failure.~~ + +Accidentally published from the 0.21.x series after a series of unfortunate events. Do not use. + # v0.22.0-M3 (2021-02-02) Inherits the fixes of v0.21.18 @@ -879,6 +1103,20 @@ Inherits the fixes of v0.21.18 * [#4335](https://github.com/http4s/http4s/pull/4335): Don't render an empty body with chunked transfer encoding on response statuses that don't permit a body (e.g., `204 No Content`). +# v1.0.0-M14 + +* [GHSA-xhv5-w9c5-2r2w](https://github.com/http4s/http4s/security/advisories/GHSA-xhv5-w9c5-2r2w): Additionally to the fix in v0.21.17, drops support for NIO2. + +## http4s-okhttp-client + +### Breaking changes + +* [#4299](https://github.com/http4s/http4s/pull/4299): Manage the `Dispatcher` internally in `OkHttpBuilder`. `create` becomes a private method. + +### Documentation + +* [#4306](https://github.com/http4s/http4s/pull/4306): Update the copyright notice to 2021. + # v0.22.0-M2 (2021-02-02) This release fixes a [High Severity vulnerability](https://github.com/http4s/http4s/security/advisories/GHSA-xhv5-w9c5-2r2w) in blaze-server. @@ -961,6 +1199,176 @@ This release fixes a [High Severity vulnerability](https://github.com/http4s/htt * blaze-0.14.15 * okhttp-4.9.1 +# v1.0.0-M13 (2021-01-25) + +This is the first milestone built on Cats-Effect 3. To track Cats-Effect 2 development, please see the new 0.22.x series. Everything in 0.22.0-M1, including the cats-parse port, is here. + +## http4s-core + +### Breaking changes + +* [#3784](https://github.com/http4s/http4s/pull/3784), [#3865](https://github.com/http4s/http4s/pull/3784): Inexhaustively, + * Many `EntityDecoder` constraints relaxed from `Sync` to `Concurrent`. + * File-related operations require a `Files` constraint. + * `Blocker` arguments are no longer required. + * `ContextShift` constraints are no longer required. + * The deprecated, non-HTTP `AsyncSyntax` is removed. +* [#3886](https://github.com/http4s/http4s/pull/3886): + * Relax `Sync` to `Defer` in `HttpApp` constructor. + * Relax `Sync` to `Concurrent` in `Logger` constructors. + * Remove `Sync` constraint from `Part` constructors. + * Relax `Sync` to `Functor` in various Kleisli syntax. + +## http4s-laws + +### Breaking changes + +* [#3807](https://github.com/http4s/http4s/pull/3807): Several arbitraries and cogens now require a `Dispatcher` and a `TestContext`. +## http4s-client + +* [#3857](https://github.com/http4s/http4s/pull/3857): Inexhaustively, + * `Monad: Clock` constraints changed to `Temporal` + * `Client.translate` requires an `Async` and `MonadCancel` + * Removal of `Blocker` from `JavaNetClientBuilder` + * `PoolManager` changed from `Concurrent` to `Async` + * Many middlewares changed from `Sync` to `Async` +* [#4081](https://github.com/http4s/http4s/pull/4081): Change `Metrics` constraints from `Temporal` to `Clock: Concurrent` + +## http4s-server + +* [#3857](https://github.com/http4s/http4s/pull/3857): Inexhaustively, + * `Monad: Clock` constraints changed to `Temporal` + * Many middlewares changed from `Sync` to `Async` +* [#4081](https://github.com/http4s/http4s/pull/4081): Change `Metrics` constraints from `Temporal` to `Clock: Concurrent` + +## http4s-async-http-client + +### Breaking changes + +* [#4149](https://github.com/http4s/http4s/pull/4149): `ConcurrentEffect` constraint relaxed to `Async`. `apply` method changed to `fromClient` and returns a `Resource` to account for the `Dispatcher`. + +## http4s-blaze-core + +### Breaking changes + +* [#3894](https://github.com/http4s/http4s/pull/3894): Most `Effect` constraints relaxed to `Async`. + +## http4s-blaze-server + +### Breaking changes + +* [#4097](https://github.com/http4s/http4s/pull/4097), [#4137](https://github.com/http4s/http4s/pull/4137): `ConcurrentEffect` constraint relaxed to `Async`. Remove deprecated `BlazeBuilder` + +## http4s-blaze-client + +### Breaking changes + +* [#4097](https://github.com/http4s/http4s/pull/4097): `ConcurrentEffect` constraint relaxed to `Async` + +## http4s-ember-client + +### Breaking changes + +* [#4256](https://github.com/http4s/http4s/pull/4256): `Concurrent: Timer: ContextShift` constraint turned to `Async` + +## http4s-ember-server + +### Breaking changes + +* [#4256](https://github.com/http4s/http4s/pull/4256): `Concurrent: Timer: ContextShift` constraint turned to `Async` + +## http4s-okhttp-client + +### Breaking changes + +* [#4102](https://github.com/http4s/http4s/pull/4102), [#4136](https://github.com/http4s/http4s/pull/4136): + * `OkHttpBuilder` takes a `Dispatcher` + * `ConcurrentEffect` and `ContextShift` constraints replaced by `Async` + +## http4s-servlet + +### Breaking changes + +* [#4175](https://github.com/http4s/http4s/pull/4175): Servlets naow take a `Dispatcher`. The blocker is removed from `BlockingIo`. `ConcurrentEffect` constraint relaxed to `Async`. + +## http4s-jetty-client + +### Breaking changes + +* [#4165](https://github.com/http4s/http4s/pull/4165): `ConcurrentEffect` constraint relaxed to `Async` + +## http4s-jetty + +### Breaking changes + +* [#4191](https://github.com/http4s/http4s/pull/4191): `ConcurrentEffect` constraint relaxed to `Async` + +## http4s-tomcat + +### Breaking changes + +* [#4216](https://github.com/http4s/http4s/pull/4216): `ConcurrentEffect` constraint relaxed to `Async` + +## http4s-jawn + +### Breaking changes + +* [#3871](https://github.com/http4s/http4s/pull/3871): `Sync` constraints relaxed to `Concurrent` + +## http4s-argonaut + +### Breaking changes + +* [#3961](https://github.com/http4s/http4s/pull/3961): `Sync` constraints relaxed to `Concurrent` + +## http4s-circe + +### Breaking changes + +* [#3965](https://github.com/http4s/http4s/pull/3965): `Sync` constraints relaxed to to `Concurrent`. + +## http4s-json4s + +### Breaking changes + +* [#3885](https://github.com/http4s/http4s/pull/3885): `Sync` constraints relaxed to to `Concurrent`. + +## http4s-play-json + +### Breaking changes + +* [#3962](https://github.com/http4s/http4s/pull/3962): `Sync` constraints relaxed to to `Concurrent`. + +## http4s-scala-xml + +### Breaking changes + +* [#4054](https://github.com/http4s/http4s/pull/4054): `Sync` constraints relaxed to to `Concurrent`. + +## http4s-boopickle + +### Breaking changes + +* [#3871](https://github.com/http4s/http4s/pull/3852): `Sync` constraints relaxed to `Concurrent` + +## Dependency updates + +* cats-effect-3.0.0-M5 +* fs2-3.0.0-M7 +* jawn-1.0.3 +* jawn-fs2-2.0.0-M2 +* keypool-0.4.0-M1 (moved to `org.typelevel`) +* log4cats-2.0.0-M1 +* vault-3.0.0-M1 + +~~# v1.0.0-M12 (2021-01-25)~~ + +Build failure + +~~# v1.0.0-M11 (2021-01-25)~~ + +Partial publish after build failure + # v0.22.0-M1 (2021-01-24) This is a new series, forked from main before Cats-Effect 3 support was merged. It is binary incompatible with 0.21, but contains several changes that will be necessary for Scala 3 (Dotty) support. It builds on all the changes from v1.0.0-M1 through v1.0.0-M10, which are not echoed here. @@ -1073,8 +1481,18 @@ The headline change is that all parboiled2 parsers have been replaced with cats- * [#4124](https://github.com/http4s/http4s/pull/4124): Avoid intermediate `ByteBuffer` duplication +# v1.0.0-M10 (2020-12-31) + +## http4s-client + +### Enhancements + +* [#4051](https://github.com/http4s/http4s/pull/4051): Add `customized` function to `Logger` middleware that takes a function to produce the log string. Add a `colored` implementation on that that adds colors to the logs. + ## Dependency updates +* argonaut-6.3.3 + * dropwizard-metrics-4.1.17 * netty-4.1.58.Final * play-json-29.9.2 diff --git a/website/src/hugo/content/code-of-conduct.md b/website/src/hugo/content/code-of-conduct.md index 1afcaa1ea4c..38abce825ec 100644 --- a/website/src/hugo/content/code-of-conduct.md +++ b/website/src/hugo/content/code-of-conduct.md @@ -38,7 +38,6 @@ These are the policies for upholding our community’s standards of conduct. If you feel that a thread needs moderation, please contact anyone on the moderation team: -- [Bryce Anderson](mailto:bryce.anderson22@gmail.com) - [Ross A. Baker](mailto:ross@rossabaker.com) - [Christopher Davenport](mailto:chris@christopherdavenport.tech) diff --git a/website/src/hugo/content/versions.md b/website/src/hugo/content/versions.md index a60b6d82352..817fb6ef51c 100644 --- a/website/src/hugo/content/versions.md +++ b/website/src/hugo/content/versions.md @@ -6,9 +6,6 @@ title: Versions ## Release lifecycle -* Snapshots of all branches - are published automatically by [Travis CI] to the [Sonatype Snapshot - repo]. * Milestone releases are published for early adopters who need the latest dependencies or new features. We will try to deprecate responsibly, but no binary @@ -22,8 +19,15 @@ title: Versions in the official support channels. Patches may be released with a working pull request accompanied by a tale of woe. -[Travis CI]: https://travis-ci.org/http4s/http4s -[Sonatype Snapshot repo]: https://oss.sonatype.org/content/repositories/snapshots/org/http4s/ +## Which version is right for me? + +* _I'm on Scala 2, and milestones scare me:_ {{% latestInSeries "0.21" %}} +* _I'll upgrade to Scala 3 before Cats-Effect 3:_ {{% latestInSeries "0.22" %}} +* _I'm ready for Cats-Effect 3:_ {{% latestInSeries "0.23" %}} +* _I'm new here, pick one:_ {{% latestInSeries "0.23" %}} +* _I live on the bleeding edge:_ {{% latestInSeries "1.0" %}} + +## @@ -34,6 +38,7 @@ title: Versions + @@ -47,17 +52,43 @@ title: Versions + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -69,6 +100,7 @@ title: Versions + @@ -80,6 +112,7 @@ title: Versions + @@ -91,6 +124,7 @@ title: Versions + @@ -102,6 +136,7 @@ title: Versions + @@ -113,6 +148,7 @@ title: Versions + @@ -124,6 +160,7 @@ title: Versions + @@ -135,6 +172,7 @@ title: Versions + @@ -146,6 +184,7 @@ title: Versions + @@ -159,6 +198,7 @@ title: Versions + @@ -172,6 +212,7 @@ title: Versions + @@ -185,6 +226,7 @@ title: Versions + @@ -198,6 +240,7 @@ title: Versions + @@ -211,6 +254,7 @@ title: Versions + @@ -224,6 +268,7 @@ title: Versions + @@ -237,6 +282,7 @@ title: Versions + @@ -250,6 +296,7 @@ title: Versions + @@ -263,6 +310,7 @@ title: Versions + diff --git a/website/src/hugo/static/_redirects b/website/src/hugo/static/_redirects index 9e510ee8480..e783f452208 100644 --- a/website/src/hugo/static/_redirects +++ b/website/src/hugo/static/_redirects @@ -1,2 +1,2 @@ -/stable/* /v0.20/:splat 200 -/latest/* /v0.21/:splat 200 +/stable/* /v0.21/:splat 200 +/latest/* /v1.0/:splat 200 diff --git a/website/src/hugo/themes/http4s.org/layouts/partials/nav-docs.html b/website/src/hugo/themes/http4s.org/layouts/partials/nav-docs.html index 0e9ac458a85..6dca377f51e 100644 --- a/website/src/hugo/themes/http4s.org/layouts/partials/nav-docs.html +++ b/website/src/hugo/themes/http4s.org/layouts/partials/nav-docs.html @@ -1,5 +1,7 @@
Scala 2.11 Scala 2.12 Scala 2.13Scala 3.0 Cats FS2 JDK 2.x3.x1.8+
{{% latestInSeries "0.23" %}}Milestone 2.x3.x 1.8+
{{% latestInSeries "0.22" %}}Milestone2.x2.x1.8+
{{% latestInSeries "0.21" %}} Stable 2.x 2.x 1.8+ 1.x 1.x 1.8+ 1.x 1.x 1.8+ 1.x 0.10.x 1.8+ 0.9.x 0.9.x 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.8+ 1.7+