Typesafe techniques
For a better sleep at night
About me
- Senior software engineer
- Working for Ocado Technology
- OSS developer
- SRE sometime ago
- Currently building business critical software
System failures
Lifecycle of an error
Runtime error
Let me tell you a story
You are having a sweet dream at night
Suddenly your phone rings
Production system error
You are trying to figure it out
By the sunrise you sort it out
You're trying not to fall asleep the next day
End of story
Compile time error
Another story
You're coding gently in your (home) office
Compiler error appears
You are trying to figure it out
Your mates are there to help
Because it's not the middle of the night
You sort it out
You can sleep well at night
End of story
Runtime
- Happens when the program is already running
- Can have significant consequences
- Wakes you up at night
Compile
- Happens as you code
- No way to deploy broken code - it doesn't compile
Wrapping up
Compile
Runtime
<
Is it possible?
Yes
Typesafe techniques
For a better sleep at night
QA walks into a bar
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
Other types
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
Real world example
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?
Real world example
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
Real world example
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)
}
}
Drawbacks
- Compiler can't help us this way
- Needs addtional tests
- What about JSON decoding?
Let's do it better
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
Advantages
- Compile time check
- No need to write tests
- Clean code
- Self documenting
- Out of the box JSON support in Circe
Things to remember
- Carefully choose your type constraints, otherwise the refactoring might be required
- Refined type is not a domain type - wrap them in AnyVal
- Refined adds runtime wrapper, this might slightly affect the performance
Is that all?
Glad that you asked
Typesafe APIs
Say hello to Tapir
Let's build a trivial API
- Goal: Order validation API
- Want to build your own? example code
- Final version available in this repo
The domain
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
The domain
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
}
The endpoint
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
The logic
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
The boring stuff
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
The test
$ 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')
The test
$ 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
The test
$ 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?
Bonus!
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
Bonus #2!
import sttp.tapir.client.sttp.SttpClientInterpreter
import sttp.model.Uri
object Client {
val serverHost = Uri.parse("https://locahost:9090").toOption
val validate: Order => Either[Unit,Unit] =
SttpClientInterpreter().toQuickClient(
OrderEndpoints.validate,
serverHost
)
}
Client implementation for free
Summing up
- Endpoint definition verified at compile time
- Data validation for free
- Easy to plug in the logic and verify if it handles the right kind of data
- Seamless interop with ecosystem (CatsEffect, Circe, Refined, and others)
- Example available at
https://github.com/majk-p/typesystem-goodies
The end
Thank you!
Typesafe techniques
By Michał
Typesafe techniques
- 275