Last active
April 30, 2018 15:38
-
-
Save dborisenko/85078d3b91c365da6af98ba2c1395107 to your computer and use it in GitHub Desktop.
Universal health-check without dependencies
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")))) | |
| ) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", "") | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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