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 b4fa813..361455a 100644 --- a/build.sbt +++ b/build.sbt @@ -3,9 +3,11 @@ import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} val catsV = "2.1.0" val catsEffectV = "2.1.1" val fs2V = "2.2.2" -val http4sV = "0.21.0" +val scodecV = "1.11.7" +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" @@ -16,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) @@ -24,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) @@ -87,7 +100,7 @@ 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, 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..2af32e0 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala @@ -1,6 +1,5 @@ package io.chrisdavenport.mules.http4s -import io.chrisdavenport.mules.http4s.internal.CachedResponse import cats._ // import cats.effect._ import cats.implicits._ @@ -11,33 +10,27 @@ import io.chrisdavenport.cats.effect.time.JavaTime * 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) ) - def age(created: HttpDate, now: HttpDate): Age = new Age(now.epochSecond - created.epochSecond) - - def cacheLifetime(expires: Option[HttpDate], now: HttpDate): Option[CacheLifetime] = expires.map{expiredAt => - new CacheLifetime(expiredAt.epochSecond - now.epochSecond) + 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) + } + 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) + } } - } \ No newline at end of file diff --git a/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CachedResponse.scala b/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala similarity index 55% rename from core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CachedResponse.scala rename to core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala index 9cf1f8f..103e850 100644 --- a/core/src/main/scala/io/chrisdavenport/mules/http4s/internal/CachedResponse.scala +++ b/core/src/main/scala/io/chrisdavenport/mules/http4s/CachedResponse.scala @@ -1,39 +1,38 @@ -package io.chrisdavenport.mules.http4s.internal +package io.chrisdavenport.mules.http4s import io.chrisdavenport.vault.Vault import org.http4s._ import fs2._ -import scodec.bits.ByteVector -import cats.Functor + +import cats._ import cats.implicits._ +import scodec.bits.ByteVector -final private[http4s] class CachedResponse private ( - val status: Status, - val httpVersion: HttpVersion, - val headers: Headers, - val body: ByteVector, - val attributes: Vault +// 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, - this.attributes + this.body ) def toResponse[F[_]]: Response[F] = CachedResponse.toResponse(this) } -private[http4s] object CachedResponse { +object CachedResponse { - def fromResponse[F[_]: Functor](response: Response[F])(implicit compiler: Stream.Compiler[F,F]): F[CachedResponse] = { - response.body.compile.to(ByteVector).map{bv => + 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, - response.attributes + bv ) } } @@ -44,6 +43,6 @@ private[http4s] object CachedResponse { cachedResponse.httpVersion, cachedResponse.headers, Stream.chunk(Chunk.byteVector(cachedResponse.body)), - cachedResponse.attributes + 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/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) diff --git a/scodec/src/main/scala/io/chrisdavenport/mules/http4s/codecs/package.scala b/scodec/src/main/scala/io/chrisdavenport/mules/http4s/codecs/package.scala new file mode 100644 index 0000000..bd258f9 --- /dev/null +++ b/scodec/src/main/scala/io/chrisdavenport/mules/http4s/codecs/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 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[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( + decode, + httpVersion => Attempt.successful(httpVersion.major -> httpVersion.minor ) + ) + } + + private[codecs] 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[codecs] 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/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/Arbitraries.scala b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/Arbitraries.scala new file mode 100644 index 0000000..d671ef7 --- /dev/null +++ b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/Arbitraries.scala @@ -0,0 +1,99 @@ +package io.chrisdavenport.mules.http4s.codecs + +import io.chrisdavenport.mules.http4s._ +import org.scalacheck._ +import _root_.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/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/CodecSpec.scala b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/CodecSpec.scala new file mode 100644 index 0000000..158ac09 --- /dev/null +++ b/scodec/src/test/scala/io/chrisdavenport/mules/http4s/codecs/CodecSpec.scala @@ -0,0 +1,31 @@ +package io.chrisdavenport.mules.http4s.codecs + +import 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 = cachedResponseCodec.encode(cached) + val decoded = encoded.flatMap(bv => cachedResponseCodec.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 = cacheItemCodec.encode(cached) + val decoded = encoded.flatMap(bv => cacheItemCodec.decode(bv)) + + decoded.toEither must beRight.like{ + case a => a.value must_=== cached + } + } + } +} \ No newline at end of file