Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Event Handling #5

Draft
wants to merge 7 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/main/scala/venix/hookla/App.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import io.getquill.context.zio._
import io.getquill.util.LoadConfig
import sttp.client3.httpclient.zio.HttpClientZioBackend
import sttp.tapir.json.circe._
import venix.hookla.RequestError.Unauthenticated
import venix.hookla.RequestError.{BadRequest, Forbidden, Unauthenticated}
import venix.hookla.http.Auth
import venix.hookla.resolvers._
import venix.hookla.services.core._
import venix.hookla.services.db._
import venix.hookla.services.http.DiscordUserService
import venix.hookla.sources.WebhookController
import zio._
import zio.http._
import zio.logging.backend.SLF4J
Expand All @@ -30,17 +31,23 @@ object App extends ZIOAppDefault {
migrationService <- ZIO.service[FlywayMigrationService]
// _ <- migrationService.migrate().orDie

schemaResolver <- ZIO.service[SchemaResolver]
webhookController <- ZIO.service[WebhookController]
schemaResolver <- ZIO.service[SchemaResolver]
api = schemaResolver.graphQL

apiInterpreter <- api.interpreter
app = Http
.collectHttp[Request] { case _ -> !! / "api" / "graphql" =>
ZHttpAdapter.makeHttpService(HttpInterpreter(apiInterpreter)) @@ Auth.middleware
.collectHttp[Request] {
case Method.POST -> !! / "api" / "v1" / "webhooks" / webhookId =>
webhookController.makeHttpService
case _ -> !! / "api" / "graphql" =>
ZHttpAdapter.makeHttpService(HttpInterpreter(apiInterpreter)) @@ Auth.middleware
}
.tapErrorCauseZIO(cause => ZIO.logErrorCause(cause))
.mapError {
case e: Unauthenticated => Response(status = Status.Unauthorized, body = Body.fromString(Json.obj("error" -> Json.fromString(e.message)).spaces2))
case e: Forbidden => Response(status = Status.Forbidden, body = Body.fromString(Json.obj("error" -> Json.fromString(e.message)).spaces2))
case e: BadRequest => Response(status = Status.BadRequest, body = Body.fromString(Json.obj("error" -> Json.fromString(e.message)).spaces2))
case _ => Response(status = Status.InternalServerError)
}

Expand Down Expand Up @@ -81,6 +88,7 @@ object App extends ZIOAppDefault {
HookResolver.live,
HookService.live,
TeamResolver.live,
WebhookController.live,
// zhttp server config
Server.defaultWithPort(8443),
logger
Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/venix/hookla/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import venix.hookla.resolvers._
import venix.hookla.services.core.{AuthService, HTTPService}
import venix.hookla.services.db.{FlywayMigrationService, HookService, UserService}
import venix.hookla.services.http.DiscordUserService
import venix.hookla.sources.WebhookController
import venix.hookla.types.RichNewtype
import zio._
import zio.http.Server
Expand All @@ -21,7 +22,7 @@ package object hookla {

object QuillContext extends PostgresZioJAsyncContext(SnakeCase)

type Env = HooklaConfig with ZioJAsyncConnection with Redis with SttpClient with Auth with UserResolver with HookResolver with HTTPService with FlywayMigrationService with DiscordUserService with SinkResolver with SourceResolver with SchemaResolver with UserResolver with UserService with HookService with AuthService with Server
type Env = HooklaConfig with ZioJAsyncConnection with Redis with SttpClient with Auth with UserResolver with HookResolver with HTTPService with FlywayMigrationService with DiscordUserService with SinkResolver with SourceResolver with SchemaResolver with UserResolver with UserService with WebhookController with HookService with AuthService with Server

type Result[T] = IO[RequestError, T]
type ResultOpt[T] = IO[RequestError, Option[T]]
Expand Down
10 changes: 10 additions & 0 deletions src/main/scala/venix/hookla/services/db/HookService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import zio.ZLayer

trait HookService extends BaseDBService {
def get(team: TeamId, hook: HookId): Result[Option[Hook]]
def get(id: HookId): Result[Option[Hook]]
// Should only be used when resolving something you know 100% exists because of SQL constraints
def getUnsafe(hook: HookId): Result[Hook]

def getByTeam(team: TeamId): Result[List[Hook]]
Expand All @@ -25,6 +27,14 @@ private class HookServiceImpl(private val ctx: ZioJAsyncConnection) extends Hook
.mapBoth(DatabaseError, _.headOption)
.provide(ZLayer.succeed(ctx))

def get(id: HookId) =
run {
hooks
.filter(_.id == lift(id))
}
.mapBoth(DatabaseError, _.headOption)
.provide(ZLayer.succeed(ctx))

def getUnsafe(hook: HookId) =
run {
hooks
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package venix.hookla.services.http

import io.circe.Codec
import io.circe.generic.semiauto._
import sttp.client3.UriContext
import venix.hookla.{HooklaConfig, Result}
import venix.hookla.services.core.{HTTPService, Options}
import zio.ZLayer

trait DiscordWebhookService {

}

private class DiscordWebhookServiceImpl(private val http: HTTPService, private val config: HooklaConfig) extends DiscordWebhookService {
def execute(id: String): Result[Option[DiscordUser]] = http.post[Option[DiscordUser]](uri"https://canary.discord.com/api/webhooks/689887952268165178/J6GACLSgtVdOKO_tP3CWVmy_PV3_3A6T8Pc2lL1b0ZUHCviVQlhk31ElB7_vJA7w_rIK", Options().addHeader("Authorization", s"Bot ${config.discord.token}"))
}

object DiscordWebhookService {
private type In = HTTPService with HooklaConfig
private def create(httpService: HTTPService, c: HooklaConfig) = new DiscordWebhookServiceImpl(httpService, c)

val live: ZLayer[In, Throwable, DiscordWebhookService] = ZLayer.fromFunction(create _)
}
19 changes: 19 additions & 0 deletions src/main/scala/venix/hookla/sources/SourceEventHandler.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package venix.hookla.sources

import io.circe.Json
import venix.hookla.Task
import venix.hookla.models.Hook

/**
* This trait is used to handle the body of a webhook, after the event type has been determined.
* The reason there is a type argument is because different sources might use different formats i.e. JSON, XML, etc...
*
* @tparam T The type of the body of the webhook (i.e. Json, String, case class, etc...)
*/
sealed trait SourceEventHandler[T <: Serializable] {
def handle(body: T, headers: Map[String, String], hook: Hook): Task[Unit]
}

trait GithubSourceEventHandler extends SourceEventHandler[Json] {
def handle(body: Json, headers: Map[String, String], hook: Hook): Task[Unit]
}
24 changes: 24 additions & 0 deletions src/main/scala/venix/hookla/sources/SourceHandler.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package venix.hookla.sources

import venix.hookla.Result
import venix.hookla.sources.github.GithubSourceHandler
import zio.{UIO, URIO, ZIO}
import zio.http.Request

private[sources] trait SourceHandler {
/*
* This method is called when a webhook is received.
* It should determine the event type and then call the appropriate method.
* The event type is determined by the source, and the source is determined by the request.
* The request is passed in so that the handler can determine the event type.
* i.e. push, issue, deployment, etc...
*/
def determineEvent(req: Request): Result[SourceEventHandler[_ <: Serializable]]
}

object SourceHandler {
def getHandlerById(id: String): UIO[SourceHandler] =
id match {
case "github" => ZIO.succeed(GithubSourceHandler)
}
}
70 changes: 70 additions & 0 deletions src/main/scala/venix/hookla/sources/WebhookController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package venix.hookla.sources

import io.circe.Json
import io.circe.syntax._
import sttp.tapir.{Endpoint, PublicEndpoint}
import sttp.tapir.server.ziohttp.{ZioHttpInterpreter, ZioHttpServerOptions}
import venix.hookla.Env
import venix.hookla.RequestError.BadRequest
import venix.hookla.services.db.HookService
import venix.hookla.types.HookId
import zio.http.{Body, HttpApp, Request, Response, Status}
import zio.{&, RIO, ZIO, ZLayer}
import sttp.tapir.ztapir._

import java.util.UUID

trait WebhookController {
def makeHttpService[R](implicit serverOptions: ZioHttpServerOptions[R] = ZioHttpServerOptions.default[R]): HttpApp[R & Env, Throwable]
}

/**
* This class contains the handling of the webhooks that are INCOMING from sources
* such as GitHub, GitLab, BitBucket, Sonarr, Radarr, etc..
*/
private class WebhookControllerImpl(
private val hookService: HookService
) extends WebhookController {
// URI: /api/v1/handle/:hookId
// Method: POST
private def handleWebhook(request: Request, body: String): ZIO[Env, String, String] = (for {
_ <- ZIO.unit // just to start the for comprehension

maybeHookId = request.url.path.dropTrailingSlash.last
_ <- ZIO.fail(BadRequest("You need to pass a webhook ID.")) when maybeHookId.isEmpty

// TODO: This is a bit ugly, but it works for now.
hookId <- ZIO.attempt(UUID.fromString(maybeHookId.get)).map(HookId(_))

hook <- hookService.get(hookId)
_ <- ZIO.fail(BadRequest("Invalid webhook ID.")) when hook.isEmpty
// TODO: Update last used timestamp

handler <- SourceHandler.getHandlerById(hook.get.sourceId)
eventHandler <- handler.determineEvent(request)

// TODO: This needs to be abstracted out to support non-JSON body's like the handler traits have.
jsonBody <- ZIO.attempt(body.asJson)

_ <- eventHandler
.asInstanceOf[GithubSourceEventHandler]
.handle(jsonBody, request.headers.map(x => x.headerName -> x.renderedValue).toMap, hook.get)
} yield Json.obj("message" -> Json.fromString("Success!")).spaces2).mapError { e => println(e); "temp" } // TODO: Figure out how to have better errors here.

private def webhookEndpoint = endpoint
.in(extractFromRequest[Request](x => x.underlying.asInstanceOf[Request]))
.in(stringJsonBody)
.errorOut(stringBody)
.out(stringJsonBody)

def makeHttpService[R](implicit serverOptions: ZioHttpServerOptions[R]): HttpApp[R & Env, Throwable] =
ZioHttpInterpreter(serverOptions)
.toHttp(webhookEndpoint.zServerLogic(c => handleWebhook(c._1, c._2)))
}

object WebhookController {
private type In = HookService
private def create(hookService: HookService) = new WebhookControllerImpl(hookService)

val live: zio.ZLayer[In, Throwable, WebhookController] = ZLayer.fromFunction(create _)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package venix.hookla.sources.github

import venix.hookla.Result
import venix.hookla.sources.{GithubSourceEventHandler, SourceHandler}
import zio.ZIO
import zio.http.Request

case object GithubSourceHandler extends SourceHandler {
private val eventMap: Map[String, GithubSourceEventHandler] = Map(
"push" -> events.PushEvent,
"ping" -> events.PingEvent
)

override def determineEvent(req: Request): Result[GithubSourceEventHandler] =
ZIO.attempt(eventMap(req.headers.get("X-GitHub-Event").get.toLowerCase)).orDie // TODO: Handle this properly
}
15 changes: 15 additions & 0 deletions src/main/scala/venix/hookla/sources/github/events/PingEvent.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package venix.hookla.sources.github.events

import io.circe.Json
import venix.hookla.Task
import venix.hookla.models.Hook
import venix.hookla.sources.GithubSourceEventHandler
import zio.ZIO

private[github] case object PingEvent extends GithubSourceEventHandler {
def handle(body: Json, headers: Map[String, String], hook: Hook): Task[Unit] = {
println("hello there")

ZIO.unit
}
}
16 changes: 16 additions & 0 deletions src/main/scala/venix/hookla/sources/github/events/PushEvent.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package venix.hookla.sources.github.events

import io.circe.Json
import venix.hookla.Task
import venix.hookla.models.Hook
import venix.hookla.sources.{GithubSourceEventHandler, SourceEventHandler}
import zio.ZIO
import zio.http.Request

private[github] case object PushEvent extends GithubSourceEventHandler {
def handle(body: Json, headers: Map[String, String], hook: Hook): Task[Unit] = {
println("hello there")

ZIO.unit
}
}
3 changes: 3 additions & 0 deletions src/main/scala/venix/hookla/sources/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package venix.hookla

package object sources {}