Skip to content

Commit

Permalink
Merge pull request #4 from ChristopherDavenport/openWithCodecSupport
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristopherDavenport authored Jun 24, 2020
2 parents e638133 + 5707217 commit 24eb1a3
Show file tree
Hide file tree
Showing 9 changed files with 239 additions and 49 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ target/
tags

.bloop
.metals
.metals
project/metals.sbt
21 changes: 17 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -16,14 +18,25 @@ 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)
.settings(
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)
Expand Down Expand Up @@ -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,

Expand Down
39 changes: 16 additions & 23 deletions core/src/main/scala/io/chrisdavenport/mules/http4s/CacheItem.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package io.chrisdavenport.mules.http4s

import io.chrisdavenport.mules.http4s.internal.CachedResponse
import cats._
// import cats.effect._
import cats.implicits._
Expand All @@ -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)
}
}

}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Expand All @@ -44,6 +43,6 @@ private[http4s] object CachedResponse {
cachedResponse.httpVersion,
cachedResponse.headers,
Stream.chunk(Chunk.byteVector(cachedResponse.body)),
cachedResponse.attributes
Vault.empty
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}

0 comments on commit 24eb1a3

Please sign in to comment.