For a better sleep at night
Let me tell you a story
Another story
Because it's not the middle of the night
class BeerOrder {
quantity: String
}
QA orders:
“1”
“9999999”
“A beer”
“0”
“-3”
“0.99999”
“A pint of beer”
“Pomidor”
“”
null
class BeerOrder {
quantity: Double
}
QA orders:
1
9999999
“A beer”
0
-3
0.99999
“A pint of beer”
“Pomidor”
“”
null
class BeerOrder {
quantity: Int
}
QA orders:
1
9999999
“A beer”
0
-3
0.99999
“A pint of beer”
“Pomidor”
“”
null
class BeerOrder {
quantity: PosInt
}
QA orders:
1
9999999
“A beer”
0
-3
0.99999
“A pint of beer”
“Pomidor”
“”
null
val uuid1: String Refined Uuid = "6736a2fb-0848-4eb7-a9f3-0556f384c952"
val uuid2: String Refined Uuid = "123"
val posint1: PosInt = 123
val posint2: PosInt = -10
val xml1: String Refined Xml = "<div>hello world</div>"
val xml2: String Refined Xml = "<div>hello /div>"
val even: Int Refined Even = 10
val odd: Int Refined Odd = 11
case class UnsafeOrderLine(product: String, quantity: Int)
// Valid order line, we want those!
UnsafeOrderLine("123", 10)
// Wait that's illegal
UnsafeOrderLine("", 10)
UnsafeOrderLine("banana", -2)
// ☝️ this will cause runtime errors 😱
How would we fix it without refined?
case class UnsafeOrderLine(product: String, quantity: Int)
object UnsafeOrderLine {
def safeApply(product: String, quantity: Int): UnsafeOrderLine = {
if(product.isEmpty())
throw new RuntimeException("Product is empty")
else if(quantity <= 0)
throw new RuntimeException("Quantity lower than 1")
else
UnsafeOrderLine(product, quantity)
}
}
// Works fine!
UnsafeOrderLine.safeApply("123", 10)
// Throws runtime exception 👇
UnsafeOrderLine.safeApply("", 10)
It works... kind of
case class UnsafeOrderLine(product: String, quantity: Int)
object UnsafeOrderLine {
def safeApply(product: String, quantity: Int): UnsafeOrderLine = {
if(product.isEmpty())
throw new RuntimeException("Product is empty")
else if(quantity <= 0)
throw new RuntimeException("Quantity lower than 1")
else
UnsafeOrderLine(product, quantity)
}
}
import eu.timepit.refined.auto._
import eu.timepit.refined.types.string._
import eu.timepit.refined.types.numeric._
case class OrderLine(product: NonEmptyString, quantity: PosInt)
OrderLine("123", 10) // Returns OrderLine
OrderLine("", 10) // Doesn't compile
Glad that you asked
import cats.data.NonEmptyList
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.types.string._
import eu.timepit.refined.types.numeric._
case class OrderLine(product: NonEmptyString, quantity: PosInt)
case class Order(orderId: String Refined Uuid, lines: NonEmptyList[OrderLine])
Let's add JSON codecs
import cats.data.NonEmptyList
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.types.string._
import eu.timepit.refined.types.numeric._
import io.circe.Codec
import io.circe.generic.semiauto._
import io.circe.refined._
case class OrderLine(product: NonEmptyString, quantity: PosInt)
object OrderLine {
implicit val codec: Codec[OrderLine] = deriveCodec
}
case class Order(orderId: String Refined Uuid, lines: NonEmptyList[OrderLine])
object Order {
implicit val codec: Codec[Order] = deriveCodec
}
import sttp.tapir._
import sttp.tapir.json.circe._
import sttp.tapir.generic.auto._
object OrderEndpoints {
val validate: PublicEndpoint[Order, Unit, Unit, Any] =
endpoint
.post
.in("validate") // defines a path http://localhost:8080/validate
.in(jsonBody[Order]) // takes Order as an input
.out(jsonBody[Unit]) // the response content is always `{}`
}
Validation
endpoint
Order in JSON format
200 for success
400 for failure
import cats.effect._
import cats.implicits._
object OrderRoutes {
val logic: Order => Unit = _ => () // define the logic
// bind the endpoint to the logic
val validate =
OrderEndpoints.validate.serverLogic(logic(_).asRight[Unit].pure[IO])
}
Tapir does the validation based on the types, so the endpoint does nothing
import cats.effect._
import org.http4s.HttpRoutes
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.server.Router
import sttp.tapir.server.http4s.Http4sServerInterpreter
import scala.concurrent.ExecutionContext
object Server {
private val routes: HttpRoutes[IO] =
Http4sServerInterpreter[IO]().toRoutes(
OrderRoutes.validate // the thing from the previous slide ;)
)
val resource = BlazeServerBuilder[IO]
.withExecutionContext(ExecutionContext.global)
.bindHttp(8080, "localhost")
.withHttpApp(Router("/" -> routes).orNotFound)
.resource
}
object Main extends IOApp {
override def run(args: List[String]): IO[ExitCode] = {
Server.resource
.useForever
.as(ExitCode.Success)
}
}
Let's wire the things together
$ echo -n '{
"orderId" : "17da8323-6e08-4519-aab6-ee0a9f9a30b3",
"lines" : [
{
"product" : "product1",
"quantity" : 10
}
]
}
' | http POST localhost:8080/validate
HTTP/1.1 200 OK
Content-Length: 2
Content-Type: application/json
Date: Tue, 28 Jun 2022 10:44:10 GMT
{}
Let's see how it work
$ echo -n '{}' | http POST localhost:8080/validate
HTTP/1.1 400 Bad Request
Content-Length: 96
Content-Type: text/plain; charset=UTF-8
Date: Tue, 28 Jun 2022 10:45:47 GMT
Invalid value for: body (Missing required field at 'orderId', Missing required field at 'lines')
$ echo -n '{
"orderId" : "17da8323-6e08-4519-aab6-ee0a9f9a30b3",
"lines" : [
{
"product" : "product1",
"quantity" : -1
}
]
}
' | http POST localhost:8080/validate
HTTP/1.1 400 Bad Request
Content-Length: 76
Content-Type: text/plain; charset=UTF-8
Date: Tue, 28 Jun 2022 12:38:26 GMT
Invalid value for: body (Predicate failed: (-1 > 0). at 'lines[0].quantity')
Let's see how it work - negative quantity
$ echo -n '{
"orderId" : "17da8323-6e08-4519-aab6-ee0a9f9a30b3",
"lines" : []
}
' | http POST localhost:8080/validate
HTTP/1.1 400 Bad Request
Content-Length: 100
Content-Type: text/plain; charset=UTF-8
Date: Tue, 28 Jun 2022 12:39:59 GMT
Invalid value for: body (Missing required field at 'lines[0]', Missing required field at 'lines[0]')
Let's see how it work - no lines
Pretty impressive for no logic, isn't it?
object Server {
import sttp.tapir._
import sttp.tapir.swagger.bundle.SwaggerInterpreter
val myEndpoints: List[AnyEndpoint] = List(OrderEndpoints.validate)
val swaggerEndpoints = SwaggerInterpreter().fromEndpoints[IO](myEndpoints, "My App", "1.0")
private val routes: HttpRoutes[IO] =
Http4sServerInterpreter[IO]().toRoutes(
List(
OrderRoutes.validate,
) ++ swaggerEndpoints
)
val resource = BlazeServerBuilder[IO]
.withExecutionContext(ExecutionContext.global)
.bindHttp(8080, "localhost")
.withHttpApp(Router("/" -> routes).orNotFound)
.resource
}
Open API docs for free
Our OSS
Thank you!
My blog and OSS: