Repository: http4s/http4s (scala)

Issue:
History Client Middleware
Browsers Keep a history of calls made by the browser. Would be nice if we had a client middleware which did similar in case we ever want to show and capture the interactions that a specific flow may have generated without leaking confidential information like headers/cookies/etc.

```scala
// Browser save Title HTML and Favicon - Maybe a later pass maybe just options that are none for now to keep bincompat
// Maybe we want a builder class for this so we can add additional information to it.

def middleware[F[_]: MonadCancelThrow](client: Client[F], history: Ref[F, List[(HttpDate, Method, Uri)], maxSize: Int): Client[F] = Client[F]{(req: Request[F]) =>
  Resource.eval(req.headers.get[Date].fold(HttpDate.now[F])(d => d.date.pure[F]).flatMap{ date => 
     val allTheOtherStuff 
     history.modify(l => event :: l) >>
     dropElementsAfterN >>
     clinet.run(req)
  }
} // obviously psuedo
```

Pull request:
Fixes #6521

Patch:
diff --git a/client/shared/src/main/scala/org/http4s/client/middleware/History.scala b/client/shared/src/main/scala/org/http4s/client/middleware/History.scala
new file mode 100644
index 00000000000..a90513d68f7
--- /dev/null
+++ b/client/shared/src/main/scala/org/http4s/client/middleware/History.scala
@@ -0,0 +1,95 @@
+/*
+ * 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.middleware
+
+import cats.effect._
+import cats.syntax.applicative._
+import org.http4s._
+import org.http4s.client.Client
+import org.http4s.headers.Date
+
+final class HistoryEntry private (val httpDate: HttpDate, val method: Method, val uri: Uri) {
+
+  override def toString: String = s"HistoryEntry(httpDate=$httpDate, method=$method, uri=$uri)"
+
+  def canEqual(other: Any): Boolean = other.isInstanceOf[HistoryEntry]
+
+  override def equals(other: Any): Boolean = other match {
+    case that: HistoryEntry =>
+      (that.canEqual(this)) &&
+      httpDate == that.httpDate &&
+      method == that.method &&
+      uri == that.uri
+    case _ => false
+  }
+
+  override def hashCode(): Int = {
+    val state = Seq(httpDate, method, uri)
+    state.map(_.hashCode()).foldLeft(0)((a, b) => 31 * a + b)
+  }
+
+}
+
+object HistoryEntry {
+  def apply(date: HttpDate, method: Method, uri: Uri): HistoryEntry =
+    new HistoryEntry(date, method, uri)
+
+}
+
+/** This Middleware provides history tracking of Uri's visited.
+  * History entries are ordered most recent to oldest.
+  *
+  * @param client: Client[F]
+  * @param history: Ref[F, Vector[HistoryEntry]
+  * @param maxSize: Int
+  */
+
+final class HistoryBuilder[F[_]: MonadCancelThrow: Clock] private (
+    private val client: Client[F],
+    private val history: Ref[F, Vector[HistoryEntry]],
+    val maxSize: Int = 1024,
+) { self =>
+
+  private def copy(
+      client: Client[F] = self.client,
+      history: Ref[F, Vector[HistoryEntry]] = self.history,
+      maxSize: Int,
+  ): HistoryBuilder[F] = new HistoryBuilder[F](client, history, maxSize)
+
+  def withMaxSize(size: Int): HistoryBuilder[F] = copy(maxSize = size)
+
+  def build: Client[F] = Client[F] { (req: Request[F]) =>
+    Resource.eval(req.headers.get[Date].fold(HttpDate.current[F])(d => d.date.pure[F])).flatMap {
+      date =>
+        val method = req.method
+        val uri = req.uri
+
+        Resource
+          .eval(this.history.update(l => (HistoryEntry(date, method, uri) +: l).take(this.maxSize)))
+          .flatMap(_ => client.run(req))
+
+    }
+  }
+}
+
+object HistoryBuilder {
+
+  def default[F[_]: MonadCancelThrow: Clock](
+      client: Client[F],
+      history: Ref[F, Vector[HistoryEntry]],
+  ): HistoryBuilder[F] = new HistoryBuilder[F](client, history)
+}


Test patch:
diff --git a/client/shared/src/test/scala/org/http4s/client/middleware/HistorySuite.scala b/client/shared/src/test/scala/org/http4s/client/middleware/HistorySuite.scala
new file mode 100644
index 00000000000..7be284d1f2b
--- /dev/null
+++ b/client/shared/src/test/scala/org/http4s/client/middleware/HistorySuite.scala
@@ -0,0 +1,137 @@
+/*
+ * 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.middleware
+
+import cats.Applicative
+import cats.effect.Clock
+import cats.effect.IO
+import cats.effect.Ref
+import org.http4s.Http4sSuite
+import org.http4s.HttpDate
+import org.http4s.HttpRoutes
+import org.http4s.Request
+import org.http4s.client.Client
+import org.http4s.dsl.io._
+import org.http4s.headers.Date
+import org.http4s.syntax.all._
+
+import java.time.Duration
+import java.time.Instant
+import scala.concurrent.duration.FiniteDuration
+
+class HistorySuite extends Http4sSuite {
+
+  private val app = HttpRoutes
+    .of[IO] { case _ =>
+      Ok()
+    }
+    .orNotFound
+
+  private val defaultClient = Client.fromHttpApp(app)
+
+  private val date1 = Date(HttpDate.unsafeFromInstant(Instant.now()))
+  private val date2 = Date(HttpDate.unsafeFromInstant(Instant.now().plus(Duration.ofMinutes(1L))))
+  private val date3 = Date(HttpDate.unsafeFromInstant(Instant.now().plus(Duration.ofMinutes(2L))))
+
+  private val req1 = Request[IO](uri = uri"/request").putHeaders(date1)
+  private val req2 = Request[IO](uri = uri"/request").putHeaders(date2)
+  private val req3 = Request[IO](uri = uri"/request").putHeaders(date3)
+
+  test("History middeware should return empty history if no sites have been visited") {
+    val expected: Vector[HistoryEntry] = Vector.empty
+
+    Ref.of[IO, Vector[HistoryEntry]](Vector.empty).flatMap { ref =>
+      HistoryBuilder.default[IO](defaultClient, ref).withMaxSize(3).build
+      ref.get.assertEquals(expected)
+    }
+  }
+
+  test("History middeware should return 1 history item if 1 site has been visited") {
+    val expected = Vector(HistoryEntry(req1.headers.get[Date].get.date, req1.method, req1.uri))
+
+    Ref.of[IO, Vector[HistoryEntry]](Vector.empty).flatMap { ref =>
+      HistoryBuilder
+        .default[IO](defaultClient, ref)
+        .withMaxSize(3)
+        .build
+        .run(req1)
+        .use(_ => IO.unit) >> ref.get
+        .assertEquals(expected)
+    }
+  }
+
+  test("History middeware should return visits in order of most recent to oldest") {
+    val expected: Vector[HistoryEntry] =
+      Vector(
+        HistoryEntry(req3.headers.get[Date].get.date, req3.method, req3.uri),
+        HistoryEntry(req2.headers.get[Date].get.date, req2.method, req2.uri),
+        HistoryEntry(req1.headers.get[Date].get.date, req1.method, req1.uri),
+      )
+
+    Ref.of[IO, Vector[HistoryEntry]](Vector.empty).flatMap { ref =>
+      HistoryBuilder.default[IO](defaultClient, ref).withMaxSize(3).build.run(req1).use_ >>
+        HistoryBuilder.default[IO](defaultClient, ref).withMaxSize(3).build.run(req2).use_ >>
+        HistoryBuilder.default[IO](defaultClient, ref).withMaxSize(3).build.run(req3).use_ >>
+        ref.get.assertEquals(expected)
+    }
+  }
+
+  test("History middeware should return max number of visits if visits exceeds maxSize") {
+
+    val expected: Vector[HistoryEntry] = Vector(
+      HistoryEntry(req3.headers.get[Date].get.date, req3.method, req3.uri),
+      HistoryEntry(req2.headers.get[Date].get.date, req2.method, req2.uri),
+    )
+
+    Ref.of[IO, Vector[HistoryEntry]](Vector.empty).flatMap { ref =>
+      val historyClient = HistoryBuilder.default[IO](defaultClient, ref).withMaxSize(2).build
+
+      historyClient.run(req1).use_ >>
+        historyClient.run(req2).use_ >>
+        historyClient.run(req3).use_ >>
+        ref.get.assertEquals(expected)
+    }
+  }
+
+  test("History middeware should allow and use Clock parameter for httpDate timestamp") {
+
+    implicit val testClock: Clock[IO] = new Clock[IO] {
+      override def applicative: Applicative[IO] = Applicative[IO]
+
+      override def monotonic: IO[FiniteDuration] =
+        IO.pure(FiniteDuration(0L, scala.concurrent.duration.HOURS))
+
+      override def realTime: IO[FiniteDuration] =
+        IO.pure(FiniteDuration(0L, scala.concurrent.duration.HOURS))
+    }
+
+    val expected: Vector[HistoryEntry] = Vector(
+      HistoryEntry(req3.headers.get[Date].get.date, req3.method, req3.uri),
+      HistoryEntry(req2.headers.get[Date].get.date, req2.method, req2.uri),
+    )
+
+    Ref.of[IO, Vector[HistoryEntry]](Vector.empty).flatMap { ref =>
+      val historyClient = HistoryBuilder.default(defaultClient, ref).withMaxSize(2).build
+
+      historyClient.run(req1).use_ >>
+        historyClient.run(req2).use_ >>
+        historyClient.run(req3).use_ >>
+        ref.get.assertEquals(expected)
+    }
+  }
+
+}