Skip to content

Instantly share code, notes, and snippets.

@dborisenko
Last active April 30, 2018 15:38
Show Gist options
  • Select an option

  • Save dborisenko/85078d3b91c365da6af98ba2c1395107 to your computer and use it in GitHub Desktop.

Select an option

Save dborisenko/85078d3b91c365da6af98ba2c1395107 to your computer and use it in GitHub Desktop.
Universal health-check without dependencies
package com.dbrsn.healthcheck
import cats.arrow.FunctionK
import cats.data.NonEmptyVector
import cats.effect.IO
import cats.implicits._
import cats.{ Applicative, Id, MonadError, ~> }
import com.dbrsn.healthcheck.HealthCheckStatus.{ Failure, Ok }
import io.circe.generic.JsonCodec
import scala.concurrent.{ ExecutionContext, Future }
import scala.language.higherKinds
import scala.util.Try
@JsonCodec(encodeOnly = true)
sealed abstract class HealthCheckStatus(val isOk: Boolean) {
def isFailure: Boolean = !isOk
}
object HealthCheckStatus {
case object Ok extends HealthCheckStatus(isOk = true)
final case class Failure(error: String) extends HealthCheckStatus(isOk = false)
def apply(isOk: Boolean, error: => String): HealthCheckStatus = if (isOk) Ok else Failure(error)
def apply(errorOrBoolean: Either[Throwable, Boolean], error: => String): HealthCheckStatus = errorOrBoolean match {
case Left(e) => Failure(e.getMessage)
case Right(false) => Failure(error)
case Right(true) => Ok
}
}
final case class HealthCheckElement[F[_]](name: String, status: F[HealthCheckStatus], metadata: Map[String, String])
final case class HealthCheck[F[_]](
statuses: NonEmptyVector[HealthCheckElement[F]]
) {
def fold[R](success: HealthCheck[Id] => R, failure: HealthCheck[Id] => R)(implicit A: MonadError[F, Throwable]): F[R] =
statuses.map { v =>
v.status.recover {
case error => Failure(error.getMessage)
}.map(s => HealthCheckElement[Id](v.name, s, v.metadata))
}.sequence[F, HealthCheckElement[Id]].map { elems =>
if (elems.exists(_.status.isFailure)) failure(HealthCheck(elems)) else success(HealthCheck(elems))
}
def withCheck(name: String, check: F[HealthCheckStatus], metadata: Map[String, String] = Map.empty): HealthCheck[F] =
HealthCheck(statuses.append(HealthCheckElement(name, check, metadata)))
def transform[G[_]](implicit NT: F ~> G): HealthCheck[G] =
HealthCheck(statuses.map(hc => HealthCheckElement[G](hc.name, NT(hc.status), hc.metadata)))
def headName: String = statuses.head.name
def headMetadata: Map[String, String] = statuses.head.metadata
}
object HealthCheck {
def ok[F[_]](name: String, metadata: Map[String, String] = Map.empty)(implicit A: Applicative[F]): HealthCheck[F] =
HealthCheck(NonEmptyVector.one(HealthCheckElement(name, A.pure(Ok), metadata)))
def ok[F[_]](name: String, resolver: String => Try[String], keys: String*)(implicit A: Applicative[F]): HealthCheck[F] =
ok[F](name, keys.flatMap(k => resolver(k).toOption.map((k, _))).toMap)
def failure[F[_]](name: String, error: String, metadata: Map[String, String] = Map.empty)(implicit A: Applicative[F]): HealthCheck[F] =
HealthCheck(NonEmptyVector.one(HealthCheckElement(name, A.pure(Failure(error)), metadata)))
implicit def idToApplicative[G[_]](implicit A: Applicative[G]): Id ~> G = new FunctionK[Id, G] {
override def apply[A](fa: A): G[A] = A.pure(fa)
}
implicit class HealthCheckIdOps(val hc: HealthCheck[Id]) extends AnyVal {
def lift[G[_]: Applicative]: HealthCheck[G] = hc.transform[G]
}
type HealthCheckKafkaTopic = String
type HealthCheckKafkaKey = String
type HealthCheckKafkaValue = String
implicit class HealthCheckIOOps(val hc: HealthCheck[IO]) extends AnyVal {
def withKafkaProducerCheck(
send: (HealthCheckKafkaTopic, HealthCheckKafkaKey, HealthCheckKafkaValue) => Future[Boolean]
): HealthCheck[IO] = hc.withCheck(
name = "KafkaProducer",
check = IO.fromFuture(IO(send("health-check", "health", "check"))).map(HealthCheckStatus(_, "Kafka Producer health-check failed"))
)
def withActorSystemCheck(
isRunning: => Boolean, actorSystemVersion: String, akkaHttpVersion: Option[String] = None
): HealthCheck[IO] = hc.withCheck(
name = "ActorSystem",
check = IO(HealthCheckStatus(isRunning, "Actor System is terminated")),
metadata = Map("akka.actor.ActorSystem.Version" -> actorSystemVersion) ++
akkaHttpVersion.map("akka.http.Version.current" -> _)
)
def withPostgresCheck(
selectOne: => Future[Vector[Int]]
)(implicit ec: ExecutionContext): HealthCheck[IO] = hc.withCheck(
name = "PostgresDatabase",
check = IO.fromFuture(IO(selectOne.map(r => HealthCheckStatus(r == Vector(1), "Database is not available"))))
)
}
}
package com.dbrsn.healthcheck
import cats.effect.Effect
import fs2.StreamApp
import org.http4s.HttpService
import org.http4s.server.blaze.BlazeBuilder
import scala.concurrent.ExecutionContext
import scala.language.higherKinds
final case class HttpServerConfig(
host: String,
port: Int
)
abstract class HealthCheckServer[F[_]: Effect](
port: Int = 8080,
host: String = "0.0.0.0",
check: () => HealthCheck[F]
)(implicit ec: ExecutionContext) extends StreamApp[F] {
def stream(args: List[String], requestShutdown: F[Unit]): fs2.Stream[F, StreamApp.ExitCode] =
new HealthCheckStream(port, host, check).stream
def run(): Unit = main(Array.empty)
}
object HealthCheckServer {
def apply[F[_]: Effect](
config: HttpServerConfig, check: () => HealthCheck[F]
)(implicit ec: ExecutionContext): HealthCheckServer[F] =
new HealthCheckServer[F](config.port, config.host, check) {}
}
class HealthCheckStream[F[_]: Effect](port: Int, host: String, check: () => HealthCheck[F]) {
private val healthCheckService: HttpService[F] = new HealthCheckService[F](check).service
def stream(implicit ec: ExecutionContext): fs2.Stream[F, StreamApp.ExitCode] = BlazeBuilder[F]
.bindHttp(port, host).mountService(healthCheckService, "/").serve
}
package com.dbrsn.healthcheck
import cats.effect.Effect
import cats.implicits._
import io.circe.syntax._
import org.http4s.HttpService
import org.http4s.circe._
import org.http4s.dsl.Http4sDsl
import scala.language.higherKinds
class HealthCheckService[F[_]: Effect](check: () => HealthCheck[F]) extends Http4sDsl[F] {
val service: HttpService[F] = HttpService[F] {
case GET -> Root / "healthcheck" => check().fold(v => Ok(v.asJson), v => ServiceUnavailable(v.asJson)).flatten
}
}
package com.dbrsn.healthcheck
import cats.effect.IO
import com.dbrsn.healthcheck.HealthCheckStatus.{ Failure, Ok }
import org.http4s.implicits._
import org.http4s.{ Method, Request, Response, Status, Uri }
import org.scalatest.{ FlatSpec, Matchers }
class HealthCheckServiceSpec extends FlatSpec with Matchers {
private def healthCheck(check: => HealthCheck[IO]): Response[IO] = {
val getHW = Request[IO](Method.GET, Uri.uri("/healthcheck"))
new HealthCheckService[IO](() => check).service.orNotFound(getHW).unsafeRunSync()
}
it should "return 200 OK if single checks passed" in {
val hc = healthCheck(HealthCheck.ok("service"))
hc.status shouldBe Status.Ok
info(hc.as[String].unsafeRunSync())
hc.as[String].unsafeRunSync() shouldBe """{"statuses":[{"name":"service","status":{"Ok":{}},"metadata":{}}]}"""
}
it should "return 200 OK if multiple checks passed" in {
val hc = healthCheck(HealthCheck.ok("service").withCheck("other-service", Ok, Map("key" -> "value")).lift[IO])
hc.status shouldBe Status.Ok
info(hc.as[String].unsafeRunSync())
hc.as[String].unsafeRunSync() shouldBe
"""{"statuses":[
|{"name":"service","status":{"Ok":{}},"metadata":{}},
|{"name":"other-service","status":{"Ok":{}},"metadata":{"key":"value"}}
|]}""".stripMargin.replaceAllLiterally("\n", "")
}
it should "return 503 Service Unavailable if one of the single check failed" in {
val hc = healthCheck(HealthCheck.failure("service", "ERROR"))
hc.status shouldBe Status.ServiceUnavailable
info(hc.as[String].unsafeRunSync())
hc.as[String].unsafeRunSync() shouldBe """{"statuses":[{"name":"service","status":{"Failure":{"error":"ERROR"}},"metadata":{}}]}"""
}
it should "return 503 Service Unavailable if one of the multiple checks failed" in {
val hc = healthCheck(HealthCheck.failure("service", "ERROR").withCheck("other-service", Ok, Map("key" -> "value")).lift[IO])
hc.status shouldBe Status.ServiceUnavailable
info(hc.as[String].unsafeRunSync())
hc.as[String].unsafeRunSync() shouldBe
"""{"statuses":[
|{"name":"service","status":{"Failure":{"error":"ERROR"}},"metadata":{}},
|{"name":"other-service","status":{"Ok":{}},"metadata":{"key":"value"}}
|]}""".stripMargin.replaceAllLiterally("\n", "")
}
it should "return 503 Service Unavailable if all of the multiple checks failed" in {
val hc = healthCheck(HealthCheck.failure("service", "ERROR").withCheck("other-service", Failure("ERROR-2"), Map("key" -> "value")).lift[IO])
hc.status shouldBe Status.ServiceUnavailable
info(hc.as[String].unsafeRunSync())
hc.as[String].unsafeRunSync() shouldBe
"""{"statuses":[
|{"name":"service","status":{"Failure":{"error":"ERROR"}},"metadata":{}},
|{"name":"other-service","status":{"Failure":{"error":"ERROR-2"}},"metadata":{"key":"value"}}
|]}""".stripMargin.replaceAllLiterally("\n", "")
}
}
package com.dbrsn
import cats.Id
import io.circe.Encoder
import io.circe.generic.semiauto.deriveEncoder
package object healthcheck {
implicit lazy val encodeHealthCheckElement: Encoder[HealthCheckElement[Id]] = deriveEncoder
implicit lazy val encodeHealthCheck: Encoder[HealthCheck[Id]] = deriveEncoder
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment