From c4f69e11c1d14ecf8ff8da8683e5b06b36fe5e4f Mon Sep 17 00:00:00 2001 From: Chris Davenport Date: Wed, 24 Jun 2020 10:49:09 -0700 Subject: [PATCH 1/5] Open Classes with Scodec Codec Support --- build.sbt | 4 + .../mules/http4s/CacheItem.scala | 46 +++++----- .../mules/http4s/CachedResponse.scala | 88 +++++++++++++++++++ .../mules/http4s/internal/CacheRules.scala | 4 +- .../http4s/internal/CachedResponse.scala | 49 ----------- .../mules/http4s/internal/Caching.scala | 4 +- 6 files changed, 120 insertions(+), 75 deletions(-) create mode 100644 core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala delete mode 100644 core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CachedResponse.scala diff --git a/build.sbt b/build.sbt index b4fa813..e641f85 100644 --- a/build.sbt +++ b/build.sbt @@ -3,6 +3,7 @@ import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} val catsV = "2.1.0" val catsEffectV = "2.1.1" val fs2V = "2.2.2" +val scodecV = "1.11.7" val http4sV = "0.21.0" val circeV = "0.13.0" val specs2V = "4.8.3" @@ -87,9 +88,12 @@ lazy val commonSettings = Seq( libraryDependencies ++= Seq( "org.typelevel" %% "cats-core" % catsV, "org.typelevel" %% "cats-effect" % catsEffectV, + "co.fs2" %% "fs2-core" % fs2V, "co.fs2" %% "fs2-io" % fs2V, + "org.scodec" %% "scodec-core" % scodecV, + "org.scodec" %% "scodec-cats" % "1.0.0", "org.http4s" %% "http4s-server" % http4sV, "org.http4s" %% "http4s-client" % http4sV, diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala index 4a7ad1e..ca0ed01 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala @@ -1,43 +1,45 @@ package io.chrisdavenport.mules.http4s -import io.chrisdavenport.mules.http4s.internal.CachedResponse import cats._ // import cats.effect._ import cats.implicits._ import org.http4s.HttpDate import io.chrisdavenport.cats.effect.time.JavaTime +import scodec._ +import scodec.codecs._ /** * Cache Items are what we place in the cache, this is exposed * so that caches can be constructed by the user for this type **/ -final class CacheItem private ( - private[http4s] val response: CachedResponse, - private[http4s] val created: HttpDate, - private[http4s] val expires: Option[HttpDate] -){ - private[http4s] def withResponse(cachedResponse: CachedResponse) = new CacheItem( - cachedResponse, - this.created, - this.expires - ) -} - -private[http4s] object CacheItem { - - final class Age private[CacheItem] (val deltaSeconds: Long) extends AnyVal - final class CacheLifetime private[CacheItem] (val deltaSeconds: Long) extends AnyVal +final case class CacheItem( + created: HttpDate, + expires: Option[HttpDate], + response: CachedResponse, +) +object CacheItem { def create[F[_]: JavaTime: MonadError[*[_], Throwable]](response: CachedResponse, expires: Option[HttpDate]): F[CacheItem] = JavaTime[F].getInstant.map(HttpDate.fromInstant).rethrow.map(date => - new CacheItem(response, date, expires) + new CacheItem(date, expires, response) + ) + + private[http4s] val httpDateCodec: Codec[HttpDate] = + int64.exmapc(i => Attempt.fromEither(HttpDate.fromEpochSecond(i).leftMap(e => Err(e.details))))( + date => Attempt.successful(date.epochSecond) ) - def age(created: HttpDate, now: HttpDate): Age = new Age(now.epochSecond - created.epochSecond) + val codec: Codec[CacheItem] = (httpDateCodec :: optional(bool, httpDateCodec) :: CachedResponse.codec).as[CacheItem] - def cacheLifetime(expires: Option[HttpDate], now: HttpDate): Option[CacheLifetime] = expires.map{expiredAt => - new CacheLifetime(expiredAt.epochSecond - now.epochSecond) + final case class Age(val deltaSeconds: Long) extends AnyVal + object Age { + def of(created: HttpDate, now: HttpDate): Age = new Age(now.epochSecond - created.epochSecond) + } + final case class CacheLifetime(val deltaSeconds: Long) extends AnyVal + object CacheLifetime { + def of(expires: Option[HttpDate], now: HttpDate): Option[CacheLifetime] = expires.map{expiredAt => + new CacheLifetime(expiredAt.epochSecond - now.epochSecond) + } } - } \ No newline at end of file diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala new file mode 100644 index 0000000..2c6ae5c --- /dev/null +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala @@ -0,0 +1,88 @@ +package io.chrisdavenport.mules.http4s + +import io.chrisdavenport.vault.Vault +import org.http4s._ +import fs2._ + +import cats._ +import cats.implicits._ +import scodec._ +import scodec.bits.ByteVector +import scodec.bits._ +import scodec.interop.cats._ +import codecs._ + +// As attributes can be unbound. We cannot cache them as they may not be safe to do so. +final case class CachedResponse( + status: Status, + httpVersion: HttpVersion, + headers: Headers, + body: ByteVector +){ + def withHeaders(headers: Headers): CachedResponse = new CachedResponse( + this.status, + this.httpVersion, + headers, + this.body + ) + def toResponse[F[_]]: Response[F] = CachedResponse.toResponse(this) +} + +object CachedResponse { + + private[http4s] val statusCodec : Codec[Status] = int16.exmap( + i => Attempt.fromEither(Status.fromInt(i).leftMap(p => Err.apply(p.details))), + s => Attempt.successful(s.code) + ) + + private[http4s] val httpVersionCodec: Codec[HttpVersion] = { + def decode(major: Int, minor: Int): Attempt[HttpVersion] = + Attempt.fromEither(HttpVersion.fromVersion(major, minor).leftMap(p => Err.apply(p.message))) + (int8 ~ int8).exmap( + decode, + httpVersion => Attempt.successful(httpVersion.major -> httpVersion.minor ) + ) + } + + private[http4s] val headersCodec : Codec[Headers] = { + cstring.exmapc{ + s => + s.split("\r\n").toList.traverse{line => + val idx = line.indexOf(':') + if (idx >= 0) { + Attempt.successful(Header(line.substring(0, idx), line.substring(idx + 1).trim)) + } else Attempt.failure[Header](Err(s"No : found in Header - $line")) + }.map(Headers(_)) + + }{h => + Attempt.successful( + h.toList.map(h => s"${h.name.toString()}:${h.value}") + .intercalate("\r\n") + ) + } + } + + val codec : Codec[CachedResponse] = + (statusCodec :: httpVersionCodec :: headersCodec :: bytes).as[CachedResponse] + + def fromResponse[F[_], G[_]: Functor](response: Response[F])(implicit compiler: Stream.Compiler[F,G]): G[CachedResponse] = { + response.body.compile.to(ByteVector).map{bv => + new CachedResponse( + response.status, + response.httpVersion, + response.headers, + bv + ) + } + } + + // No Attributes are populated on cached entries + def toResponse[F[_]](cachedResponse: CachedResponse): Response[F] = + Response( + cachedResponse.status, + cachedResponse.httpVersion, + cachedResponse.headers, + Stream.chunk(Chunk.byteVector(cachedResponse.body)), + Vault.empty + ) +} \ No newline at end of file diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CacheRules.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CacheRules.scala index 6f782db..da2c7c9 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CacheRules.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CacheRules.scala @@ -55,8 +55,8 @@ private[http4s] object CacheRules { // accept stale data, then age is not ok. item.expires.map(expiresAt => expiresAt >= now).getOrElse(true) case Some(`Cache-Control`(values)) => - val age = CacheItem.age(item.created, now) - val lifetime = CacheItem.cacheLifetime(item.expires, now) + val age = CacheItem.Age.of(item.created, now) + val lifetime = CacheItem.CacheLifetime.of(item.expires, now) val maxAgeMet: Boolean = values.toList .collectFirst{ case c@CacheDirective.`max-age`(_) => c } diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CachedResponse.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CachedResponse.scala deleted file mode 100644 index 9cf1f8f..0000000 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CachedResponse.scala +++ /dev/null @@ -1,49 +0,0 @@ -package io.chrisdavenport.mules.http4s.internal - -import io.chrisdavenport.vault.Vault -import org.http4s._ -import fs2._ -import scodec.bits.ByteVector -import cats.Functor -import cats.implicits._ - -final private[http4s] class CachedResponse private ( - val status: Status, - val httpVersion: HttpVersion, - val headers: Headers, - val body: ByteVector, - val attributes: Vault -){ - def withHeaders(headers: Headers): CachedResponse = new CachedResponse( - this.status, - this.httpVersion, - headers, - this.body, - this.attributes - ) - def toResponse[F[_]]: Response[F] = CachedResponse.toResponse(this) -} - -private[http4s] object CachedResponse { - - def fromResponse[F[_]: Functor](response: Response[F])(implicit compiler: Stream.Compiler[F,F]): F[CachedResponse] = { - response.body.compile.to(ByteVector).map{bv => - new CachedResponse( - response.status, - response.httpVersion, - response.headers, - bv, - response.attributes - ) - } - } - - def toResponse[F[_]](cachedResponse: CachedResponse): Response[F] = - Response( - cachedResponse.status, - cachedResponse.httpVersion, - cachedResponse.headers, - Stream.chunk(Chunk.byteVector(cachedResponse.body)), - cachedResponse.attributes - ) -} \ No newline at end of file diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/Caching.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/Caching.scala index b30e597..c76afde 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/Caching.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/Caching.scala @@ -57,9 +57,9 @@ private[http4s] class Caching[F[_]: MonadError[*[_], Throwable]: JavaTime] priva val cached = item.response cached.withHeaders(resp.headers ++ cached.headers).pure[F] } - .getOrElse(CachedResponse.fromResponse(resp)) + .getOrElse(CachedResponse.fromResponse[F, F](resp)) ) - case _ => CachedResponse.fromResponse(resp) + case _ => CachedResponse.fromResponse[F, F](resp) } now <- JavaTime[F].getInstant.map(HttpDate.fromInstant).rethrow expires = CacheRules.FreshnessAndExpiration.getExpires(now, resp) From 9d9fc5fc0f491867a8a42cc3d2d45a0381389626 Mon Sep 17 00:00:00 2001 From: Chris Davenport Date: Wed, 24 Jun 2020 12:31:41 -0700 Subject: [PATCH 2/5] Add Property Test and Fix Exposed Issue on Empty Headers --- .../mules/http4s/CachedResponse.scala | 18 ++-- .../mules/http4s/Arbitraries.scala | 98 +++++++++++++++++++ .../mules/http4s/CodecSpec.scala | 30 ++++++ 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 core/src/test/scala/io/chrisdavenport/mules/http4s/Arbitraries.scala create mode 100644 core/src/test/scala/io/chrisdavenport/mules/http4s/CodecSpec.scala diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala index 2c6ae5c..2124330 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala @@ -47,12 +47,15 @@ object CachedResponse { private[http4s] val headersCodec : Codec[Headers] = { cstring.exmapc{ s => - s.split("\r\n").toList.traverse{line => - val idx = line.indexOf(':') - if (idx >= 0) { - Attempt.successful(Header(line.substring(0, idx), line.substring(idx + 1).trim)) - } else Attempt.failure[Header](Err(s"No : found in Header - $line")) - }.map(Headers(_)) + if (s.isEmpty()) + Attempt.successful(Headers.empty) + else + s.split("\r\n").toList.traverse{line => + val idx = line.indexOf(':') + if (idx >= 0) { + Attempt.successful(Header(line.substring(0, idx), line.substring(idx + 1).trim)) + } else Attempt.failure[Header](Err(s"No : found in Header - $line")) + }.map(Headers(_)) }{h => Attempt.successful( @@ -66,7 +69,7 @@ object CachedResponse { (statusCodec :: httpVersionCodec :: headersCodec :: bytes).as[CachedResponse] def fromResponse[F[_], G[_]: Functor](response: Response[F])(implicit compiler: Stream.Compiler[F,G]): G[CachedResponse] = { - response.body.compile.to(ByteVector).map{bv => + response.body.compile.to(ByteVector).map{bv => new CachedResponse( response.status, response.httpVersion, @@ -76,7 +79,6 @@ object CachedResponse { } } - // No Attributes are populated on cached entries def toResponse[F[_]](cachedResponse: CachedResponse): Response[F] = Response( cachedResponse.status, diff --git a/core/src/test/scala/io/chrisdavenport/mules/http4s/Arbitraries.scala b/core/src/test/scala/io/chrisdavenport/mules/http4s/Arbitraries.scala new file mode 100644 index 0000000..3757987 --- /dev/null +++ b/core/src/test/scala/io/chrisdavenport/mules/http4s/Arbitraries.scala @@ -0,0 +1,98 @@ +package io.chrisdavenport.mules.http4s + +import org.scalacheck._ +import scodec.bits.ByteVector +import org.http4s._ +import org.http4s.util.CaseInsensitiveString +import java.time._ + +trait Arbitraries { + implicit val arbitraryByteVector: Arbitrary[ByteVector] = + Arbitrary(Gen.containerOf[Array, Byte](Arbitrary.arbitrary[Byte]).map(ByteVector(_))) + + implicit val arbStatus: Arbitrary[Status] = + Arbitrary{ + Gen.choose(100, 599).map(Status.fromInt(_).fold(throw _, identity)) // Safe because we are in valid range + } + + implicit val arbHttpVersion : Arbitrary[HttpVersion] = Arbitrary { + for { + major <- Gen.choose(0, 9) + minor <- Gen.choose(0, 9) + } yield HttpVersion.fromVersion(major, minor).fold(throw _ , identity) + } + + val genVchar: Gen[Char] = + Gen.oneOf('\u0021' to '\u007e') + + val genFieldVchar: Gen[Char] = + genVchar + + val genTchar: Gen[Char] = Gen.oneOf { + Seq('!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~') ++ + ('0' to '9') ++ ('A' to 'Z') ++ ('a' to 'z') + } + + val genToken: Gen[String] = + Gen.nonEmptyListOf(genTchar).map(_.mkString) + + val genFieldContent: Gen[String] = + for { + head <- genFieldVchar + tail <- Gen.containerOf[Vector, Vector[Char]]( + Gen.frequency( + 9 -> genFieldVchar.map(Vector(_)), + 1 -> (for { + spaces <- Gen.nonEmptyContainerOf[Vector, Char](Gen.oneOf(' ', '\t')) + fieldVchar <- genFieldVchar + } yield spaces :+ fieldVchar) + ) + ).map(_.flatten) + } yield (head +: tail).mkString + + val genFieldValue: Gen[String] = + genFieldContent + + implicit val http4sTestingArbitraryForRawHeader: Arbitrary[Header.Raw] = + Arbitrary { + for { + token <- genToken + value <- genFieldValue + } yield Header.Raw(CaseInsensitiveString(token), value) + } + + implicit val headers: Arbitrary[Headers] = Arbitrary(Gen.listOf(Arbitrary.arbitrary[Header.Raw]).map(Headers(_))) + + implicit val arbCachedResponse: Arbitrary[CachedResponse] = Arbitrary( + for { + status <- Arbitrary.arbitrary[Status] + version <- Arbitrary.arbitrary[HttpVersion] + headers <- Arbitrary.arbitrary[Headers] + bv <- Arbitrary.arbitrary[ByteVector] + } yield CachedResponse(status, version, headers, bv) + ) + + val genHttpDate: Gen[HttpDate] = { + val min = ZonedDateTime + .of(1900, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")) + .toInstant + .toEpochMilli / 1000 + val max = ZonedDateTime + .of(9999, 12, 31, 23, 59, 59, 0, ZoneId.of("UTC")) + .toInstant + .toEpochMilli / 1000 + Gen.choose[Long](min, max).map(HttpDate.unsafeFromEpochSecond) + } + + implicit val arbHttpDate: Arbitrary[HttpDate] = Arbitrary(genHttpDate) + + implicit val arbCacheItem = Arbitrary( + for { + created <- Arbitrary.arbitrary[HttpDate] + expires <- Arbitrary.arbitrary[Option[HttpDate]] + cachedResponse <- Arbitrary.arbitrary[CachedResponse] + } yield CacheItem(created, expires, cachedResponse) + ) +} + +object Arbitraries extends Arbitraries \ No newline at end of file diff --git a/core/src/test/scala/io/chrisdavenport/mules/http4s/CodecSpec.scala b/core/src/test/scala/io/chrisdavenport/mules/http4s/CodecSpec.scala new file mode 100644 index 0000000..92ef2a3 --- /dev/null +++ b/core/src/test/scala/io/chrisdavenport/mules/http4s/CodecSpec.scala @@ -0,0 +1,30 @@ +package io.chrisdavenport.mules.http4s + +import Arbitraries._ + +class CodecSpec extends org.specs2.mutable.Specification with org.specs2.ScalaCheck { + + "CachedResponse Codec" should { + "round trip succesfully" in prop{ cached: CachedResponse => + + val encoded = CachedResponse.codec.encode(cached) + val decoded = encoded.flatMap(bv => CachedResponse.codec.decode(bv)) + + decoded.toEither must beRight.like{ + case a => a.value must_=== cached + } + } + } + + "CacheItem Codec" should { + "round trip succesfully" in prop { cached: CacheItem => + + val encoded = CacheItem.codec.encode(cached) + val decoded = encoded.flatMap(bv => CacheItem.codec.decode(bv)) + + decoded.toEither must beRight.like{ + case a => a.value must_=== cached + } + } + } +} \ No newline at end of file From 98eda9d97b17a936377832334b55121289a13c05 Mon Sep 17 00:00:00 2001 From: Christopher Davenport Date: Wed, 24 Jun 2020 15:26:10 -0700 Subject: [PATCH 3/5] New Package Structure --- .gitignore | 3 +- build.sbt | 21 +++++--- .../mules/http4s/CacheItem.scala | 9 ---- .../mules/http4s/CachedResponse.scala | 42 --------------- .../mules/http4s/scodec/package.scala | 54 +++++++++++++++++++ .../mules/http4s/scodec}/Arbitraries.scala | 5 +- .../mules/http4s/scodec}/CodecSpec.scala | 11 ++-- 7 files changed, 80 insertions(+), 65 deletions(-) create mode 100644 scodec/src/main/scala/io/chrisdavenport/mules/http4s/scodec/package.scala rename {core/src/test/scala/io/chrisdavenport/mules/http4s => scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec}/Arbitraries.scala (96%) rename {core/src/test/scala/io/chrisdavenport/mules/http4s => scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec}/CodecSpec.scala (60%) diff --git a/.gitignore b/.gitignore index 64d4a1f..e620014 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ target/ tags .bloop -.metals \ No newline at end of file +.metals +project/metals.sbt \ No newline at end of file diff --git a/build.sbt b/build.sbt index e641f85..361455a 100644 --- a/build.sbt +++ b/build.sbt @@ -4,9 +4,10 @@ val catsV = "2.1.0" val catsEffectV = "2.1.1" val fs2V = "2.2.2" val scodecV = "1.11.7" -val http4sV = "0.21.0" +val scodecCatsV = "1.0.0" +val http4sV = "0.21.4" val circeV = "0.13.0" -val specs2V = "4.8.3" +val specs2V = "4.10.0" val mulesV = "0.4.0" @@ -17,7 +18,7 @@ val betterMonadicForV = "0.3.1" lazy val `mules-http4s` = project.in(file(".")) .disablePlugins(MimaPlugin) .enablePlugins(NoPublishPlugin) - .aggregate(core) + .aggregate(core, scodec) lazy val core = project.in(file("core")) .settings(commonSettings) @@ -25,6 +26,17 @@ lazy val core = project.in(file("core")) name := "mules-http4s" ) +lazy val scodec = project.in(file("scodec")) + .settings(commonSettings) + .dependsOn(core) + .settings( + name := "mules-http4s-scodec", + libraryDependencies ++= Seq( + "org.scodec" %% "scodec-core" % scodecV, + "org.scodec" %% "scodec-cats" % scodecCatsV, + ) + ) + lazy val site = project.in(file("site")) .disablePlugins(MimaPlugin) .enablePlugins(MicrositesPlugin) @@ -89,11 +101,8 @@ lazy val commonSettings = Seq( "org.typelevel" %% "cats-core" % catsV, "org.typelevel" %% "cats-effect" % catsEffectV, - "co.fs2" %% "fs2-core" % fs2V, "co.fs2" %% "fs2-io" % fs2V, - "org.scodec" %% "scodec-core" % scodecV, - "org.scodec" %% "scodec-cats" % "1.0.0", "org.http4s" %% "http4s-server" % http4sV, "org.http4s" %% "http4s-client" % http4sV, diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala index ca0ed01..673cef2 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala @@ -5,8 +5,6 @@ import cats._ import cats.implicits._ import org.http4s.HttpDate import io.chrisdavenport.cats.effect.time.JavaTime -import scodec._ -import scodec.codecs._ /** * Cache Items are what we place in the cache, this is exposed @@ -25,13 +23,6 @@ object CacheItem { new CacheItem(date, expires, response) ) - private[http4s] val httpDateCodec: Codec[HttpDate] = - int64.exmapc(i => Attempt.fromEither(HttpDate.fromEpochSecond(i).leftMap(e => Err(e.details))))( - date => Attempt.successful(date.epochSecond) - ) - - val codec: Codec[CacheItem] = (httpDateCodec :: optional(bool, httpDateCodec) :: CachedResponse.codec).as[CacheItem] - final case class Age(val deltaSeconds: Long) extends AnyVal object Age { def of(created: HttpDate, now: HttpDate): Age = new Age(now.epochSecond - created.epochSecond) diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala index 2124330..103e850 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala @@ -6,11 +6,7 @@ import fs2._ import cats._ import cats.implicits._ -import scodec._ import scodec.bits.ByteVector -import scodec.bits._ -import scodec.interop.cats._ -import codecs._ // As attributes can be unbound. We cannot cache them as they may not be safe to do so. final case class CachedResponse( @@ -30,44 +26,6 @@ final case class CachedResponse( object CachedResponse { - private[http4s] val statusCodec : Codec[Status] = int16.exmap( - i => Attempt.fromEither(Status.fromInt(i).leftMap(p => Err.apply(p.details))), - s => Attempt.successful(s.code) - ) - - private[http4s] val httpVersionCodec: Codec[HttpVersion] = { - def decode(major: Int, minor: Int): Attempt[HttpVersion] = - Attempt.fromEither(HttpVersion.fromVersion(major, minor).leftMap(p => Err.apply(p.message))) - (int8 ~ int8).exmap( - decode, - httpVersion => Attempt.successful(httpVersion.major -> httpVersion.minor ) - ) - } - - private[http4s] val headersCodec : Codec[Headers] = { - cstring.exmapc{ - s => - if (s.isEmpty()) - Attempt.successful(Headers.empty) - else - s.split("\r\n").toList.traverse{line => - val idx = line.indexOf(':') - if (idx >= 0) { - Attempt.successful(Header(line.substring(0, idx), line.substring(idx + 1).trim)) - } else Attempt.failure[Header](Err(s"No : found in Header - $line")) - }.map(Headers(_)) - - }{h => - Attempt.successful( - h.toList.map(h => s"${h.name.toString()}:${h.value}") - .intercalate("\r\n") - ) - } - } - - val codec : Codec[CachedResponse] = - (statusCodec :: httpVersionCodec :: headersCodec :: bytes).as[CachedResponse] - def fromResponse[F[_], G[_]: Functor](response: Response[F])(implicit compiler: Stream.Compiler[F,G]): G[CachedResponse] = { response.body.compile.to(ByteVector).map{bv => new CachedResponse( diff --git a/scodec/src/main/scala/io/chrisdavenport/mules/http4s/scodec/package.scala b/scodec/src/main/scala/io/chrisdavenport/mules/http4s/scodec/package.scala new file mode 100644 index 0000000..6584578 --- /dev/null +++ b/scodec/src/main/scala/io/chrisdavenport/mules/http4s/scodec/package.scala @@ -0,0 +1,54 @@ +package io.chrisdavenport.mules.http4s + +import cats.implicits._ +import _root_.scodec.interop.cats._ +import _root_.scodec._ +import _root_.scodec.codecs._ +import org.http4s._ + +package object scodec { + private[scodec] val statusCodec : Codec[Status] = int16.exmap( + i => Attempt.fromEither(Status.fromInt(i).leftMap(p => Err.apply(p.details))), + s => Attempt.successful(s.code) + ) + + private[scodec] val httpVersionCodec: Codec[HttpVersion] = { + def decode(major: Int, minor: Int): Attempt[HttpVersion] = + Attempt.fromEither(HttpVersion.fromVersion(major, minor).leftMap(p => Err.apply(p.message))) + (int8 ~ int8).exmap( + decode, + httpVersion => Attempt.successful(httpVersion.major -> httpVersion.minor ) + ) + } + + private[scodec] val headersCodec : Codec[Headers] = { + cstring.exmapc{ + s => + if (s.isEmpty()) + Attempt.successful(Headers.empty) + else + s.split("\r\n").toList.traverse{line => + val idx = line.indexOf(':') + if (idx >= 0) { + Attempt.successful(Header(line.substring(0, idx), line.substring(idx + 1).trim)) + } else Attempt.failure[Header](Err(s"No : found in Header - $line")) + }.map(Headers(_)) + + }{h => + Attempt.successful( + h.toList.map(h => s"${h.name.toString()}:${h.value}") + .intercalate("\r\n") + ) + } + } + + private[scodec] val httpDateCodec: Codec[HttpDate] = + int64.exmapc(i => Attempt.fromEither(HttpDate.fromEpochSecond(i).leftMap(e => Err(e.details))))( + date => Attempt.successful(date.epochSecond) + ) + + val cachedResponseCodec : Codec[CachedResponse] = + (statusCodec :: httpVersionCodec :: headersCodec :: bytes).as[CachedResponse] + + val cacheItemCodec: Codec[CacheItem] = (httpDateCodec :: optional(bool, httpDateCodec) :: cachedResponseCodec).as[CacheItem] +} \ No newline at end of file diff --git a/core/src/test/scala/io/chrisdavenport/mules/http4s/Arbitraries.scala b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/Arbitraries.scala similarity index 96% rename from core/src/test/scala/io/chrisdavenport/mules/http4s/Arbitraries.scala rename to scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/Arbitraries.scala index 3757987..ddb9416 100644 --- a/core/src/test/scala/io/chrisdavenport/mules/http4s/Arbitraries.scala +++ b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/Arbitraries.scala @@ -1,7 +1,8 @@ -package io.chrisdavenport.mules.http4s +package io.chrisdavenport.mules.http4s.scodec +import io.chrisdavenport.mules.http4s._ import org.scalacheck._ -import scodec.bits.ByteVector +import _root_.scodec.bits.ByteVector import org.http4s._ import org.http4s.util.CaseInsensitiveString import java.time._ diff --git a/core/src/test/scala/io/chrisdavenport/mules/http4s/CodecSpec.scala b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/CodecSpec.scala similarity index 60% rename from core/src/test/scala/io/chrisdavenport/mules/http4s/CodecSpec.scala rename to scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/CodecSpec.scala index 92ef2a3..b9f19ae 100644 --- a/core/src/test/scala/io/chrisdavenport/mules/http4s/CodecSpec.scala +++ b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/CodecSpec.scala @@ -1,5 +1,6 @@ -package io.chrisdavenport.mules.http4s +package io.chrisdavenport.mules.http4s.scodec +import io.chrisdavenport.mules.http4s._ import Arbitraries._ class CodecSpec extends org.specs2.mutable.Specification with org.specs2.ScalaCheck { @@ -7,8 +8,8 @@ class CodecSpec extends org.specs2.mutable.Specification with org.specs2.ScalaCh "CachedResponse Codec" should { "round trip succesfully" in prop{ cached: CachedResponse => - val encoded = CachedResponse.codec.encode(cached) - val decoded = encoded.flatMap(bv => CachedResponse.codec.decode(bv)) + val encoded = cachedResponseCodec.encode(cached) + val decoded = encoded.flatMap(bv => cachedResponseCodec.decode(bv)) decoded.toEither must beRight.like{ case a => a.value must_=== cached @@ -19,8 +20,8 @@ class CodecSpec extends org.specs2.mutable.Specification with org.specs2.ScalaCh "CacheItem Codec" should { "round trip succesfully" in prop { cached: CacheItem => - val encoded = CacheItem.codec.encode(cached) - val decoded = encoded.flatMap(bv => CacheItem.codec.decode(bv)) + val encoded = cacheItemCodec.encode(cached) + val decoded = encoded.flatMap(bv => cacheItemCodec.decode(bv)) decoded.toEither must beRight.like{ case a => a.value must_=== cached From b4690d718ce4084d28b0aa899bc9fa711d667368 Mon Sep 17 00:00:00 2001 From: Christopher Davenport Date: Wed, 24 Jun 2020 15:30:59 -0700 Subject: [PATCH 4/5] Change Package to codecs --- .../mules/http4s/{scodec => codecs}/package.scala | 10 +++++----- .../mules/http4s/{scodec => codecs}/Arbitraries.scala | 2 +- .../mules/http4s/{scodec => codecs}/CodecSpec.scala | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) rename scodec/src/main/scala/io/chrisdavenport/mules/http4s/{scodec => codecs}/package.scala (85%) rename scodec/src/test/scala/io/chrisdavenport/mules/http4s/{scodec => codecs}/Arbitraries.scala (98%) rename scodec/src/test/scala/io/chrisdavenport/mules/http4s/{scodec => codecs}/CodecSpec.scala (94%) diff --git a/scodec/src/main/scala/io/chrisdavenport/mules/http4s/scodec/package.scala b/scodec/src/main/scala/io/chrisdavenport/mules/http4s/codecs/package.scala similarity index 85% rename from scodec/src/main/scala/io/chrisdavenport/mules/http4s/scodec/package.scala rename to scodec/src/main/scala/io/chrisdavenport/mules/http4s/codecs/package.scala index 6584578..bd258f9 100644 --- a/scodec/src/main/scala/io/chrisdavenport/mules/http4s/scodec/package.scala +++ b/scodec/src/main/scala/io/chrisdavenport/mules/http4s/codecs/package.scala @@ -6,13 +6,13 @@ import _root_.scodec._ import _root_.scodec.codecs._ import org.http4s._ -package object scodec { - private[scodec] val statusCodec : Codec[Status] = int16.exmap( +package object codecs { + private[codecs] val statusCodec : Codec[Status] = int16.exmap( i => Attempt.fromEither(Status.fromInt(i).leftMap(p => Err.apply(p.details))), s => Attempt.successful(s.code) ) - private[scodec] val httpVersionCodec: Codec[HttpVersion] = { + private[codecs] val httpVersionCodec: Codec[HttpVersion] = { def decode(major: Int, minor: Int): Attempt[HttpVersion] = Attempt.fromEither(HttpVersion.fromVersion(major, minor).leftMap(p => Err.apply(p.message))) (int8 ~ int8).exmap( @@ -21,7 +21,7 @@ package object scodec { ) } - private[scodec] val headersCodec : Codec[Headers] = { + private[codecs] val headersCodec : Codec[Headers] = { cstring.exmapc{ s => if (s.isEmpty()) @@ -42,7 +42,7 @@ package object scodec { } } - private[scodec] val httpDateCodec: Codec[HttpDate] = + private[codecs] val httpDateCodec: Codec[HttpDate] = int64.exmapc(i => Attempt.fromEither(HttpDate.fromEpochSecond(i).leftMap(e => Err(e.details))))( date => Attempt.successful(date.epochSecond) ) diff --git a/scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/Arbitraries.scala b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/Arbitraries.scala similarity index 98% rename from scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/Arbitraries.scala rename to scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/Arbitraries.scala index ddb9416..d671ef7 100644 --- a/scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/Arbitraries.scala +++ b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/Arbitraries.scala @@ -1,4 +1,4 @@ -package io.chrisdavenport.mules.http4s.scodec +package io.chrisdavenport.mules.http4s.codecs import io.chrisdavenport.mules.http4s._ import org.scalacheck._ diff --git a/scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/CodecSpec.scala b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/CodecSpec.scala similarity index 94% rename from scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/CodecSpec.scala rename to scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/CodecSpec.scala index b9f19ae..158ac09 100644 --- a/scodec/src/test/scala/io/chrisdavenport/mules/http4s/scodec/CodecSpec.scala +++ b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/CodecSpec.scala @@ -1,4 +1,4 @@ -package io.chrisdavenport.mules.http4s.scodec +package io.chrisdavenport.mules.http4s.codecs import io.chrisdavenport.mules.http4s._ import Arbitraries._ From 570721727893d4d8e89e320ed5cefc7729a6e3c1 Mon Sep 17 00:00:00 2001 From: Christopher Davenport Date: Wed, 24 Jun 2020 15:44:02 -0700 Subject: [PATCH 5/5] Make Age and Lifetime Private --- .../scala/io/chrisdavenport/mules/http4s/CacheItem.scala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala index 673cef2..2af32e0 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala @@ -23,12 +23,12 @@ object CacheItem { new CacheItem(date, expires, response) ) - final case class Age(val deltaSeconds: Long) extends AnyVal - object Age { + private[http4s] final case class Age(val deltaSeconds: Long) extends AnyVal + private[http4s] object Age { def of(created: HttpDate, now: HttpDate): Age = new Age(now.epochSecond - created.epochSecond) } - final case class CacheLifetime(val deltaSeconds: Long) extends AnyVal - object CacheLifetime { + private[http4s] final case class CacheLifetime(val deltaSeconds: Long) extends AnyVal + private[http4s] object CacheLifetime { def of(expires: Option[HttpDate], now: HttpDate): Option[CacheLifetime] = expires.map{expiredAt => new CacheLifetime(expiredAt.epochSecond - now.epochSecond) }