Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

http4s DSL support #220

Merged
merged 7 commits into from
Jul 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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