Skip to content

Commit

Permalink
Merge pull request #220 from theiterators/http4s
Browse files Browse the repository at this point in the history
http4s DSL support
  • Loading branch information
pk044 committed Jul 8, 2022
2 parents 34f0ba2 + e12f66c commit 1c1ef8f
Show file tree
Hide file tree
Showing 9 changed files with 406 additions and 2 deletions.
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ A library maintained by [Iterators](https://www.iteratorshq.com).
* [spray-json](#--kebs-eliminates-spray-json-induced-boilerplate-kebs-spray-json)
* [play-json](#--kebs-eliminates-play-json-induced-boilerplate-kebs-play-json)
* [akka-http](#--kebs-generates-akka-http-unmarshaller-kebs-akka-http)
* [http4s](#--kebs-provides-helpers-for-http4s)
* [circe](#--kebs-eliminates-circe-induced-boilerplate-kebs-circe)
* [Tagged types](#tagged-types)
* [JsonSchema support](#jsonschema-support)
Expand All @@ -26,7 +27,7 @@ A library maintained by [Iterators](https://www.iteratorshq.com).
### Why?

`kebs` is for eliminating some common sources of Scala boilerplate code that arise when you use
Slick (`kebs-slick`), Doobie (`kebs-doobie`), Spray (`kebs-spray-json`), Play (`kebs-play-json`), Circe (`kebs-circe`), Akka HTTP (`kebs-akka-http`).
Slick (`kebs-slick`), Doobie (`kebs-doobie`), Spray (`kebs-spray-json`), Play (`kebs-play-json`), Circe (`kebs-circe`), Akka HTTP (`kebs-akka-http`), http4s (`kebs-http4s`).

### SBT

Expand Down Expand Up @@ -62,6 +63,10 @@ Support for `akka-http`

`libraryDependencies += "pl.iterators" %% "kebs-akka-http" % "1.9.3"`

Support for `http4s`

`libraryDependencies += "pl.iterators" %% "http4s" % "1.9.3"`

Support for `tagged types`

`libraryDependencies += "pl.iterators" %% "kebs-tagged" % "1.9.3"`
Expand Down Expand Up @@ -707,6 +712,53 @@ val route = get {

```

#### - kebs provides helpers for http4s

Kebs makes it easy to use 1-element case-classes, opaque types (Scala 3), `enumeratum` or native Scala 3 enums in its DSL:

```scala
import java.util.UUID
import java.time.Year
import java.util.Currency

import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._

import pl.iterators.kebs.opaque.Opaque
import pl.iterators.kebs.http4s.{given, _}
import pl.iterators.kebs.instances.KebsInstances._ // optional, if you want instances support, ex. java.util.Currency

opaque type Age = Int
object Age extends Opaque[Age, Int] {
override def validate(value: Int): Either[String, Age] =
if (value < 0) Left("No going back, sorry") else Right(value)
}

case class UserId(id: UUID)

enum Color {
case Red, Blue, Green
}

object AgeQueryParamDecoderMatcher extends QueryParamDecoderMatcher[Age]("age")
object OptionalYearParamDecoderMatcher extends OptionalQueryParamDecoderMatcher[Year]("year")
object ValidatingColorQueryParamDecoderMatcher extends ValidatingQueryParamDecoderMatcher[Color]("color")

val routes = HttpRoutes.of[IO] {
case GET -> Root / "WrappedInt" / WrappedInt[Age](age) => ...
case GET -> Root / "InstanceString" / InstanceString[Currency](currency) => ...
case GET -> Root / "EnumString" / EnumString[Color](color) => ...
case GET -> Root / "WrappedUUID" / WrappedUUID[UserId](userId) => ...
case GET -> Root / "WrappedIntParam" :? AgeQueryParamDecoderMatcher(age) => ...
case GET -> Root / "InstanceIntParam" :? OptionalYearParamDecoderMatcher(year) => ...
case GET -> Root / "EnumStringParam" :? ValidatingColorQueryParamDecoderMatcher(color) => ...
}
```

In Scala 2, some more boilerplate is required due to https://github.com/scala/bug/issues/884. See [tests](https://github.com/theiterators/kebs/blob/master/http4s/src/test/scala-2/pl/iterators/kebs/Http4sDslTests.scala)
for more details.

### Tagged types

Starting with version 1.6.0, kebs contain an implementation of, so-called, `tagged types`. If you want to know what a `tagged type` is, please see eg.
Expand Down
25 changes: 24 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ def akkaHttpInExamples = {
akkaHttpSprayJson.cross(CrossVersion.for3Use2_13))
}

val http4sVersion = "0.23.12"
val http4s = "org.http4s" %% "http4s-dsl" % http4sVersion

def akkaHttpInBenchmarks = akkaHttpInExamples :+ (akkaHttpTestkit).cross(CrossVersion.for3Use2_13)

lazy val commonSettings = baseSettings ++ Seq(
Expand Down Expand Up @@ -228,6 +231,13 @@ lazy val akkaHttpSettings = commonSettings ++ Seq(
scalacOptions ++= paradiseFlag(scalaVersion.value)
)

lazy val http4sSettings = commonSettings ++ Seq(
libraryDependencies += http4s,
libraryDependencies += optionalEnumeratum.cross(CrossVersion.for3Use2_13),
libraryDependencies ++= paradisePlugin(scalaVersion.value),
scalacOptions ++= paradiseFlag(scalaVersion.value)
)

lazy val jsonschemaSettings = commonSettings ++ Seq(
libraryDependencies += jsonschema.cross(CrossVersion.for3Use2_13)
)
Expand Down Expand Up @@ -356,7 +366,7 @@ lazy val circeSupport = project

lazy val akkaHttpSupport = project
.in(file("akka-http"))
.dependsOn(macroUtils, instances, tagged, taggedMeta % "test -> test")
.dependsOn(macroUtils, instances, tagged % "test -> test", taggedMeta % "test -> test")
.settings(akkaHttpSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala("3"))
Expand All @@ -367,6 +377,18 @@ lazy val akkaHttpSupport = project
crossScalaVersions := supportedScalaVersions
)

lazy val http4sSupport = project
.in(file("http4s"))
.dependsOn(macroUtils, instances, opaque % "test -> test", tagged % "test -> test", taggedMeta % "test -> test")
.settings(http4sSettings: _*)
.settings(publishSettings: _*)
.settings(
name := "http4s",
description := "Automatic generation of http4s deserializers for 1-element case classes, opaque and tagged types",
moduleName := "kebs-http4s",
crossScalaVersions := supportedScalaVersions
)

lazy val jsonschemaSupport = project
.in(file("jsonschema"))
.dependsOn(macroUtils)
Expand Down Expand Up @@ -492,6 +514,7 @@ lazy val kebs = project
jsonschemaSupport,
scalacheckSupport,
akkaHttpSupport,
http4sSupport,
taggedMeta,
instances
)
Expand Down
61 changes: 61 additions & 0 deletions http4s/src/main/scala-2/pl/iterators/kebs/Http4s.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package pl.iterators.kebs

import enumeratum.EnumEntry

import scala.util.Try
import pl.iterators.kebs.macros.CaseClass1Rep
import pl.iterators.kebs.macros.enums.EnumOf
import org.http4s._
import pl.iterators.kebs.instances.InstanceConverter

import java.util.UUID

trait Http4s {
protected class PathVar[A](cast: String => Try[A]) {
def unapply(str: String): Option[A] =
if (str.nonEmpty)
cast(str).toOption
else
None
}

object WrappedString {
def apply[T](implicit rep: CaseClass1Rep[T, String]) = new PathVar[T](str => Try(rep.apply(str)))
}

object InstanceString {
def apply[T](implicit rep: InstanceConverter[T, String]) = new PathVar[T](str => Try(rep.decode(str)))
}

object EnumString {
def apply[T <: EnumEntry](implicit e: EnumOf[T]) = new PathVar[T](str => Try(e.`enum`.values.find(_.toString.toUpperCase == str.toUpperCase).getOrElse(throw new IllegalArgumentException(s"enum case not found: $str"))))
}

object WrappedInt {
def apply[T](implicit rep: CaseClass1Rep[T, Int]) = new PathVar[T](str => Try(rep.apply(str.toInt)))
}

object InstanceInt {
def apply[T](implicit rep: InstanceConverter[T, Int]) = new PathVar[T](str => Try(rep.decode(str.toInt)))
}

object WrappedLong {
def apply[T](implicit rep: CaseClass1Rep[T, Long]) = new PathVar[T](str => Try(rep.apply(str.toLong)))
}

object InstanceLong {
def apply[T](implicit rep: InstanceConverter[T, Long]) = new PathVar[T](str => Try(rep.decode(str.toLong)))
}

object WrappedUUID {
def apply[T](implicit rep: CaseClass1Rep[T, UUID]) = new PathVar[T](str => Try(rep.apply(UUID.fromString(str))))
}

object InstanceUUID {
def apply[T](implicit rep: InstanceConverter[T, UUID]) = new PathVar[T](str => Try(rep.decode(UUID.fromString(str))))
}

implicit def cc1RepQueryParamDecoder[T, U](implicit rep: CaseClass1Rep[T, U], qpd: QueryParamDecoder[U]): QueryParamDecoder[T] = qpd.emap(u => Try(rep.apply(u)).toEither.left.map(t => ParseFailure(t.getMessage, t.getMessage)))
implicit def instanceConverterQueryParamDecoder[T, U](implicit rep: InstanceConverter[T, U], qpd: QueryParamDecoder[U]): QueryParamDecoder[T] = qpd.emap(u => Try(rep.decode(u)).toEither.left.map(t => ParseFailure(t.getMessage, t.getMessage)))
implicit def enumQueryParamDecoder[E <: EnumEntry](implicit e: EnumOf[E]): QueryParamDecoder[E] = QueryParamDecoder[String].emap(str => Try(e.`enum`.values.find(_.toString.toUpperCase == str.toUpperCase).getOrElse(throw new IllegalArgumentException(s"enum case not found: $str"))).toEither.left.map(t => ParseFailure(t.getMessage, t.getMessage)))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package pl.iterators.kebs

package object http4s extends Http4s
57 changes: 57 additions & 0 deletions http4s/src/main/scala-3/pl/iterators/kebs/http4s/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package pl.iterators.kebs.http4s

import scala.util.Try
import scala.reflect.Enum
import pl.iterators.kebs.macros.CaseClass1Rep
import pl.iterators.kebs.macros.enums.EnumOf
import org.http4s._
import pl.iterators.kebs.instances.InstanceConverter
import java.util.UUID

protected class PathVar[A](cast: String => Try[A]) {
def unapply(str: String): Option[A] =
if (str.nonEmpty)
cast(str).toOption
else
None
}

object WrappedString {
def apply[T](using rep: CaseClass1Rep[T, String]) = new PathVar[T](str => Try(rep.apply(str)))
}

object InstanceString {
def apply[T](using rep: InstanceConverter[T, String]) = new PathVar[T](str => Try(rep.decode(str)))
}

object EnumString {
def apply[T <: Enum](using e: EnumOf[T]) = new PathVar[T](str => Try(e.`enum`.values.find(_.toString.toUpperCase == str.toUpperCase).getOrElse(throw new IllegalArgumentException(s"enum case not found: $str"))))
}

object WrappedInt {
def apply[T](using rep: CaseClass1Rep[T, Int]) = new PathVar[T](str => Try(rep.apply(str.toInt)))
}

object InstanceInt {
def apply[T](using rep: InstanceConverter[T, Int]) = new PathVar[T](str => Try(rep.decode(str.toInt)))
}

object WrappedLong {
def apply[T](using rep: CaseClass1Rep[T, Long]) = new PathVar[T](str => Try(rep.apply(str.toLong)))
}

object InstanceLong {
def apply[T](using rep: InstanceConverter[T, Long]) = new PathVar[T](str => Try(rep.decode(str.toLong)))
}

object WrappedUUID {
def apply[T](using rep: CaseClass1Rep[T, UUID]) = new PathVar[T](str => Try(rep.apply(UUID.fromString(str))))
}

object InstanceUUID {
def apply[T](using rep: InstanceConverter[T, UUID]) = new PathVar[T](str => Try(rep.decode(UUID.fromString(str))))
}

given[T, U](using rep: CaseClass1Rep[T, U], qpd: QueryParamDecoder[U]): QueryParamDecoder[T] = qpd.emap(u => Try(rep.apply(u)).toEither.left.map(t => ParseFailure(t.getMessage, t.getMessage)))
given[T, U](using rep: InstanceConverter[T, U], qpd: QueryParamDecoder[U]): QueryParamDecoder[T] = qpd.emap(u => Try(rep.decode(u)).toEither.left.map(t => ParseFailure(t.getMessage, t.getMessage)))
given[E <: Enum](using e: EnumOf[E]): QueryParamDecoder[E] = QueryParamDecoder[String].emap(str => Try(e.`enum`.values.find(_.toString.toUpperCase == str.toUpperCase).getOrElse(throw new IllegalArgumentException(s"enum case not found: $str"))).toEither.left.map(t => ParseFailure(t.getMessage, t.getMessage)))
29 changes: 29 additions & 0 deletions http4s/src/test/scala-2/pl/iterators/kebs/Domain.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package pl.iterators.kebs

import enumeratum.{Enum, EnumEntry}
import pl.iterators.kebs.tag.meta.tagged
import pl.iterators.kebs.tagged._

import java.util.UUID

@tagged trait Domain {
trait AgeTag
type Age = Int @@ AgeTag
object Age {
def validate(value: Int): Either[String, Int] =
if (value < 0) Left("No going back, sorry") else Right(value)
}
}

object Domain extends Domain {
case class UserId(id: UUID)

sealed trait Color extends EnumEntry
object Color extends Enum[Color] {
case object Red extends Color
case object Blue extends Color
case object Green extends Color

override def values = findValues
}
}
83 changes: 83 additions & 0 deletions http4s/src/test/scala-2/pl/iterators/kebs/Http4sDslTests.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package pl.iterators.kebs

import cats.effect.IO
import cats.effect.unsafe.IORuntime
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers

import java.time.Year
import java.util.Currency
import pl.iterators.kebs.instances.KebsInstances._
import pl.iterators.kebs.http4s._

class Http4sDslTests extends AnyFunSuite with Matchers {
import Domain._

implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global

object AgeQueryParamDecoderMatcher extends QueryParamDecoderMatcher[Age]("age")

object OptionalYearParamDecoderMatcher extends OptionalQueryParamDecoderMatcher[Year]("year")

object ValidatingColorQueryParamDecoderMatcher extends ValidatingQueryParamDecoderMatcher[Color]("color")

// this indirection comes from: https://github.com/scala/bug/issues/884
val AgeVar = WrappedInt[Age]
val CurrencyVar = InstanceString[Currency]
val UserIdVar = WrappedUUID[UserId]
val ColorVar = EnumString[Color]

val routes = HttpRoutes.of[IO] {
case GET -> Root / "WrappedInt" / AgeVar(age) => Ok(age.toString)
case GET -> Root / "InstanceString" / CurrencyVar(currency) => Ok(currency.getClass.toString)
case GET -> Root / "EnumString" / ColorVar(color) => Ok(color.toString)
case GET -> Root / "WrappedUUID" / UserIdVar(userId) => Ok(userId.toString)
case GET -> Root / "WrappedIntParam" :? AgeQueryParamDecoderMatcher(age) => Ok(age.toString)
case GET -> Root / "InstanceIntParam" :? OptionalYearParamDecoderMatcher(year) => Ok(year.toString)
case GET -> Root / "EnumStringParam" :? ValidatingColorQueryParamDecoderMatcher(color) => Ok(color.toString)
}

private def runPathGetBody(path: Uri): String = {
routes.orNotFound.run(Request(method = Method.GET, uri = path)).unsafeRunSync().body.compile.fold[String]("")(_ + _.toChar).unsafeRunSync()
}

test("WrappedInt + Opaque") {
runPathGetBody(uri"/WrappedInt/42") shouldBe "42"
runPathGetBody(uri"/WrappedInt/-42") shouldBe "Not found"
}

test("InstanceString + Currency") {
runPathGetBody(uri"/InstanceString/USD") shouldBe "class java.util.Currency"
runPathGetBody(uri"/InstanceString/NOPE") shouldBe "Not found"
}

test("WrappedUUID") {
runPathGetBody(uri"/WrappedUUID/8cc82b40-71bc-4e50-ac7e-8227013f37ea") shouldBe "UserId(8cc82b40-71bc-4e50-ac7e-8227013f37ea)"
runPathGetBody(uri"/WrappedUUID/NOPE") shouldBe "Not found"
}

test("EnumString") {
runPathGetBody(uri"/EnumString/Red") shouldBe "Red"
runPathGetBody(uri"/EnumString/BlUe") shouldBe "Blue"
runPathGetBody(uri"/EnumString/GrEEn") shouldBe "Green"
runPathGetBody(uri"/EnumString/Yellow") shouldBe "Not found"
}

test("WrappedIntParam + Opaque") {
runPathGetBody(uri"/WrappedIntParam?age=42") shouldBe "42"
runPathGetBody(uri"/WrappedIntParam?age=-42") shouldBe "Not found"
}

test("InstanceIntParam + Year") {
runPathGetBody(uri"/InstanceIntParam?year=2022") shouldBe "Some(2022)"
runPathGetBody(uri"/InstanceIntParam?year=-2147483647") shouldBe "Not found"
}

test("EnumStringParam") {
runPathGetBody(uri"/EnumStringParam?color=RED") shouldBe "Valid(Red)"
runPathGetBody(uri"/EnumStringParam?color=YELLow") shouldBe "Invalid(NonEmptyList(org.http4s.ParseFailure: enum case not found: YELLow: enum case not found: YELLow))"
}
}
Loading

0 comments on commit 1c1ef8f

Please sign in to comment.