Skip to content

Commit

Permalink
✨ Add Tapir integration
Browse files Browse the repository at this point in the history
  • Loading branch information
jwojnowski committed Feb 2, 2024
1 parent ee93855 commit c4aeef4
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 56 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,5 @@ import me.wojnowski.scuid.circe._

## TODO
- [x] Circe integration
- [ ] Tapir integration
- [x] Tapir integration
- [ ] ZIO integration
17 changes: 14 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway
ThisBuild / tlBaseVersion := "0.1" // your current series x.y
ThisBuild / tlBaseVersion := "0.2" // your current series x.y

ThisBuild / organization := "me.wojnowski"
ThisBuild / organizationName := "Jakub Wojnowski"
Expand All @@ -18,7 +18,7 @@ val Scala213 = "2.13.12"
ThisBuild / crossScalaVersions := Seq(Scala213, "3.3.1")
ThisBuild / scalaVersion := Scala213 // the default Scala

lazy val root = tlCrossRootProject.aggregate(core)
lazy val root = tlCrossRootProject.aggregate(core, circe, tapir)

lazy val core =
project
Expand All @@ -39,7 +39,7 @@ lazy val core =
lazy val circe =
project
.in(file("circe"))
.dependsOn(core)
.dependsOn(core % "compile->compile;test->test")
.settings(
name := "scuid-circe",
libraryDependencies ++= Seq(
Expand All @@ -49,3 +49,14 @@ lazy val circe =
"org.scalameta" %% "munit-scalacheck" % "0.7.29" % Test
)
)

lazy val tapir =
project
.in(file("tapir"))
.dependsOn(core % "compile->compile;test->test")
.settings(
name := "scuid-tapir",
libraryDependencies ++= Seq(
"com.softwaremill.sttp.tapir" %% "tapir-core" % "1.9.6"
)
)
9 changes: 5 additions & 4 deletions circe/src/main/scala/me/wojnowski/scuid/circe/Codecs.scala
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package me.wojnowski.scuid.circe

import io.circe.Decoder
import io.circe.Encoder
import me.wojnowski.scuid.Cuid2
import me.wojnowski.scuid.Cuid2Custom
import me.wojnowski.scuid.Cuid2Long

import io.circe.Decoder
import io.circe.Encoder

trait Codecs {
implicit val cuid2Encoder: Encoder[Cuid2] = Encoder[String].contramap(_.render)
implicit val cuid2LongEncoder: Encoder[Cuid2Long] = Encoder[String].contramap(_.render)
implicit val cuid2Encoder: Encoder[Cuid2] = Encoder[String].contramap(_.render)
implicit val cuid2LongEncoder: Encoder[Cuid2Long] = Encoder[String].contramap(_.render)
implicit def cuid2CustomEncoder[L <: Int]: Encoder[Cuid2Custom[L]] = Encoder[String].contramap(_.render)

implicit val cuid2Decoder: Decoder[Cuid2] =
Expand Down
32 changes: 12 additions & 20 deletions circe/src/test/scala/me/wojnowski/scuid/circe/CodecsTest.scala
Original file line number Diff line number Diff line change
@@ -1,69 +1,61 @@
package me.wojnowski.scuid.circe

import io.circe.syntax.*
import me.wojnowski.scuid.Cuid2
import me.wojnowski.scuid.Cuid2Custom
import me.wojnowski.scuid.Cuid2Long
import munit.ScalaCheckSuite
import me.wojnowski.scuid.Generators

import org.scalacheck.Gen
import org.scalacheck.Prop.forAll
import org.scalacheck.Test

class CodecsTest extends ScalaCheckSuite {
import io.circe.syntax.*
import munit.ScalaCheckSuite

class CodecsTest extends ScalaCheckSuite with Generators {
override protected def scalaCheckTestParameters: Test.Parameters =
super.scalaCheckTestParameters.withMinSuccessfulTests(200)

test("Cuid2 encoding/decoding (success)") {
forAll(cuid2Gen(24)) { rawCuid2 =>
forAll(validRawCuidGen(24)) { rawCuid2 =>
val cuid2 = Cuid2.validate(rawCuid2).get
assertEquals(cuid2.asJson, rawCuid2.asJson)
assertEquals(rawCuid2.asJson.as[Cuid2], Right(cuid2))
}
}

test("Cuid2 decoding (failure)") {
forAll(cuid2GenRandomLengthButNot(24)) { rawInvalidCuid2 =>
forAll(validRawCuid2GenRandomLengthButNot(24)) { rawInvalidCuid2 =>
assert(rawInvalidCuid2.asJson.as[Cuid2].isLeft)
}
}

test("Cuid2Long encoding/decoding (success)") {
forAll(cuid2Gen(32)) { rawCuid2 =>
forAll(validRawCuidGen(32)) { rawCuid2 =>
val cuid2 = Cuid2Long.validate(rawCuid2).get
assertEquals(cuid2.asJson, rawCuid2.asJson)
assertEquals(rawCuid2.asJson.as[Cuid2Long], Right(cuid2))
}
}

test("Cuid2Long decoding (failure)") {
forAll(cuid2GenRandomLengthButNot(32)) { rawInvalidCuid2 =>
forAll(validRawCuid2GenRandomLengthButNot(32)) { rawInvalidCuid2 =>
assert(rawInvalidCuid2.asJson.as[Cuid2Long].isLeft)
}
}

test("Cuid2Custom encoding/decoding (success)") {
forAll(cuid2Gen(27)) { rawCuid2 =>
forAll(validRawCuidGen(27)) { rawCuid2 =>
val cuid2 = Cuid2Custom.validate[27](rawCuid2).get
assertEquals(cuid2.asJson, rawCuid2.asJson)
assertEquals(rawCuid2.asJson.as[Cuid2Custom[27]], Right(cuid2))
}
}

test("Cuid2Custom decoding (failure)") {
forAll(cuid2GenRandomLengthButNot(27)) { rawInvalidCuid2 =>
forAll(validRawCuid2GenRandomLengthButNot(27)) { rawInvalidCuid2 =>
assert(rawInvalidCuid2.asJson.as[Cuid2Custom[27]].isLeft)
}
}

private def cuid2Gen(length: Int): Gen[String] =
for {
prefix <- Gen.alphaLowerChar
suffix <- Gen.stringOfN(length - 1, Gen.oneOf(Gen.alphaLowerChar, Gen.numChar))
} yield s"$prefix$suffix"

private def cuid2GenRandomLengthButNot(length: Int): Gen[String] =
Gen.chooseNum(4, 36).retryUntil(_ != length).flatMap { length =>
cuid2Gen(length)
}

}
33 changes: 5 additions & 28 deletions core/src/test/scala/me/wojnowski/scuid/Cuid2ValidationTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,57 +21,34 @@

package me.wojnowski.scuid

import me.wojnowski.scuid.Cuid2ValidationTest.*

import org.scalacheck.Gen
import org.scalacheck.Prop

import munit.ScalaCheckSuite

class Cuid2ValidationTest extends ScalaCheckSuite {
class Cuid2ValidationTest extends ScalaCheckSuite with Generators {
property("Validate - succeeds for valid CUID2") {
Prop.forAll(validRawCuid(27)) { rawCuid =>
Prop.forAll(validRawCuidGen(27)) { rawCuid =>
assertEquals(Cuid2Custom.validate[27](rawCuid).map(_.render), Some(rawCuid))
}
}

property("Validate - fails with non-lowercase first character") {
Prop.forAll(mkRawCuid(nonLowercaseGen, lowercaseAlphanumGen, 27)) { rawCuid =>
Prop.forAll(rawCuidLikeGen(nonLowercaseGen, lowercaseAlphanumGen, 27)) { rawCuid =>
assertEquals(Cuid2Custom.validate[27](rawCuid), None)
}
}

property("Validate - fails with non-lowercase alphanum characters following the letter") {
Prop.forAll(mkRawCuid(lowercaseGen, nonLowercaseAlphanumGen, 27)) { rawCuid =>
Prop.forAll(rawCuidLikeGen(lowercaseGen, nonLowercaseAlphanumGen, 27)) { rawCuid =>
assertEquals(Cuid2Custom.validate[27](rawCuid), None)
}
}

test("Validate - fails with incorrect length") {
Prop.forAll(Gen.chooseNum(4, 64).suchThat(_ != 27).flatMap(length => validRawCuid(length))) { rawCuid =>
Prop.forAll(Gen.chooseNum(4, 64).suchThat(_ != 27).flatMap(length => validRawCuidGen(length))) { rawCuid =>
assertEquals(Cuid2Custom.validate[27](rawCuid), None)
}
}

}

object Cuid2ValidationTest {
private val printableChars = 32.toChar to 126.toChar

val lowercaseGen: Gen[Char] = Gen.alphaLowerChar

val nonLowercaseGen: Gen[Char] = Gen.oneOf(printableChars.filterNot(_.isLower))

val lowercaseAlphanumGen: Gen[Char] = Gen.oneOf(lowercaseGen, Gen.numChar)

val nonLowercaseAlphanumGen: Gen[Char] = Gen.oneOf(printableChars.filterNot(_.isLower).filterNot(_.isDigit))

def validRawCuid(length: Int): Gen[String] = mkRawCuid(lowercaseGen, lowercaseAlphanumGen, length)

def mkRawCuid(firstChar: Gen[Char], otherChars: Gen[Char], length: Int): Gen[String] =
for {
firstLetter <- firstChar
otherChars <- Gen.stringOfN(length - 1, otherChars)
} yield s"$firstLetter$otherChars"

}
29 changes: 29 additions & 0 deletions core/src/test/scala/me/wojnowski/scuid/Generators.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package me.wojnowski.scuid

import org.scalacheck.Gen

trait Generators {
private val printableChars = 32.toChar to 126.toChar

val lowercaseGen: Gen[Char] = Gen.alphaLowerChar

val nonLowercaseGen: Gen[Char] = Gen.oneOf(printableChars.filterNot(_.isLower))

val lowercaseAlphanumGen: Gen[Char] = Gen.oneOf(lowercaseGen, Gen.numChar)

val nonLowercaseAlphanumGen: Gen[Char] = Gen.oneOf(printableChars.filterNot(_.isLower).filterNot(_.isDigit))

def validRawCuidGen(length: Int): Gen[String] = rawCuidLikeGen(lowercaseGen, lowercaseAlphanumGen, length)

def rawCuidLikeGen(firstChar: Gen[Char], otherChars: Gen[Char], length: Int): Gen[String] =
for {
firstLetter <- firstChar
otherChars <- Gen.stringOfN(length - 1, otherChars)
} yield s"$firstLetter$otherChars"

def validRawCuid2GenRandomLengthButNot(length: Int): Gen[String] =
Gen.chooseNum(4, 36).retryUntil(_ != length).flatMap { length =>
validRawCuidGen(length)
}

}
27 changes: 27 additions & 0 deletions tapir/src/main/scala/me/wojnowski/scuid/tapir/TapirCodec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package me.wojnowski.scuid.tapir

import me.wojnowski.scuid.Cuid2
import me.wojnowski.scuid.Cuid2Custom
import me.wojnowski.scuid.Cuid2Long

import sttp.tapir.Codec
import sttp.tapir.Codec.PlainCodec
import sttp.tapir.Schema

trait TapirCodec {
implicit val schemaForCuid2: Schema[Cuid2] = Schema.string.format("Cuid2 (length 24)")

implicit val codecForCuid2: PlainCodec[Cuid2] =
Codec.string.mapEither(Cuid2.validate(_).toRight("Invalid Cuid2 (length 24)"))(_.render)

implicit val schemaForCuid2Long: Schema[Cuid2Long] = Schema.string.format("Cuid2 (length 24)")

implicit val codecForCuid2Long: PlainCodec[Cuid2Long] =
Codec.string.mapEither(Cuid2Long.validate(_).toRight("Invalid Cuid2 (length 24)"))(_.render)

implicit def schemaForCuid2Custom[L <: Int](implicit L: ValueOf[L]): Schema[Cuid2Custom[L]] =
Schema.string.format(s"Cuid2 (length ${L.value})")

implicit def codecForCuid2Custom[L <: Int](implicit L: ValueOf[L]): PlainCodec[Cuid2Custom[L]] =
Codec.string.mapEither(Cuid2Custom.validate[L](_).toRight(s"Invalid Cuid2 (length ${L.value})"))(_.render)
}
3 changes: 3 additions & 0 deletions tapir/src/main/scala/me/wojnowski/scuid/tapir/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package me.wojnowski.scuid

package object tapir extends TapirCodec
61 changes: 61 additions & 0 deletions tapir/src/test/scala/me/wojnowski/scuid/tapir/TapirCodecTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package me.wojnowski.scuid.tapir

import me.wojnowski.scuid.Cuid2
import me.wojnowski.scuid.Cuid2Custom
import me.wojnowski.scuid.Cuid2Long
import me.wojnowski.scuid.Generators

import org.scalacheck.Prop.forAll
import org.scalacheck.Test

import munit.ScalaCheckSuite
import sttp.tapir.DecodeResult

class TapirCodecTest extends ScalaCheckSuite with Generators {
override protected def scalaCheckTestParameters: Test.Parameters =
super.scalaCheckTestParameters.withMinSuccessfulTests(200)

test("Cuid2 encoding/decoding (success)") {
forAll(validRawCuidGen(24)) { rawCuid2 =>
val cuid2 = Cuid2.validate(rawCuid2).get

assertEquals(codecForCuid2.encode(cuid2), rawCuid2)
assertEquals(codecForCuid2.decode(rawCuid2), DecodeResult.Value(cuid2))
}
}

test("Cuid2 decoding (failure)") {
forAll(validRawCuid2GenRandomLengthButNot(24)) { rawInvalidCuid2 =>
assert(codecForCuid2.decode(rawInvalidCuid2).isInstanceOf[DecodeResult.Failure])
}
}

test("Cuid2Long encoding/decoding (success)") {
forAll(validRawCuidGen(32)) { rawCuid2 =>
val cuid2 = Cuid2Long.validate(rawCuid2).get
assertEquals(codecForCuid2Long.encode(cuid2), rawCuid2)
assertEquals(codecForCuid2Long.decode(rawCuid2), DecodeResult.Value(cuid2))
}
}

test("Cuid2Long decoding (failure)") {
forAll(validRawCuid2GenRandomLengthButNot(32)) { rawInvalidCuid2 =>
assert(codecForCuid2.decode(rawInvalidCuid2).isInstanceOf[DecodeResult.Failure])
}
}

test("Cuid2Custom encoding/decoding (success)") {
forAll(validRawCuidGen(27)) { rawCuid2 =>
val cuid2 = Cuid2Custom.validate[27](rawCuid2).get
assertEquals(codecForCuid2Custom[27].encode(cuid2), rawCuid2)
assertEquals(codecForCuid2Custom[27].decode(rawCuid2), DecodeResult.Value(cuid2))
}
}

test("Cuid2Custom decoding (failure)") {
forAll(validRawCuid2GenRandomLengthButNot(27)) { rawInvalidCuid2 =>
assert(codecForCuid2Custom[27].decode(rawInvalidCuid2).isInstanceOf[DecodeResult.Failure])
}
}

}

0 comments on commit c4aeef4

Please sign in to comment.