diff --git a/src/main/scala/venix/hookla/App.scala b/src/main/scala/venix/hookla/App.scala index 20b48cf..af2cc35 100644 --- a/src/main/scala/venix/hookla/App.scala +++ b/src/main/scala/venix/hookla/App.scala @@ -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 @@ -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) } @@ -81,6 +88,7 @@ object App extends ZIOAppDefault { HookResolver.live, HookService.live, TeamResolver.live, + WebhookController.live, // zhttp server config Server.defaultWithPort(8443), logger diff --git a/src/main/scala/venix/hookla/package.scala b/src/main/scala/venix/hookla/package.scala index 9d9f1e8..6af251e 100644 --- a/src/main/scala/venix/hookla/package.scala +++ b/src/main/scala/venix/hookla/package.scala @@ -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 @@ -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]] diff --git a/src/main/scala/venix/hookla/services/db/HookService.scala b/src/main/scala/venix/hookla/services/db/HookService.scala index 9f3aa94..690366d 100644 --- a/src/main/scala/venix/hookla/services/db/HookService.scala +++ b/src/main/scala/venix/hookla/services/db/HookService.scala @@ -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]] @@ -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 diff --git a/src/main/scala/venix/hookla/services/http/DiscordWebhookService.scala b/src/main/scala/venix/hookla/services/http/DiscordWebhookService.scala new file mode 100644 index 0000000..e9c6dc1 --- /dev/null +++ b/src/main/scala/venix/hookla/services/http/DiscordWebhookService.scala @@ -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 _) +} diff --git a/src/main/scala/venix/hookla/sources/SourceEventHandler.scala b/src/main/scala/venix/hookla/sources/SourceEventHandler.scala new file mode 100644 index 0000000..856d3df --- /dev/null +++ b/src/main/scala/venix/hookla/sources/SourceEventHandler.scala @@ -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] +} diff --git a/src/main/scala/venix/hookla/sources/SourceHandler.scala b/src/main/scala/venix/hookla/sources/SourceHandler.scala new file mode 100644 index 0000000..521b136 --- /dev/null +++ b/src/main/scala/venix/hookla/sources/SourceHandler.scala @@ -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) + } +} diff --git a/src/main/scala/venix/hookla/sources/WebhookController.scala b/src/main/scala/venix/hookla/sources/WebhookController.scala new file mode 100644 index 0000000..fe2cd2b --- /dev/null +++ b/src/main/scala/venix/hookla/sources/WebhookController.scala @@ -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 _) +} diff --git a/src/main/scala/venix/hookla/sources/github/GithubSourceHandler.scala b/src/main/scala/venix/hookla/sources/github/GithubSourceHandler.scala new file mode 100644 index 0000000..af83071 --- /dev/null +++ b/src/main/scala/venix/hookla/sources/github/GithubSourceHandler.scala @@ -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 +} diff --git a/src/main/scala/venix/hookla/sources/github/events/PingEvent.scala b/src/main/scala/venix/hookla/sources/github/events/PingEvent.scala new file mode 100644 index 0000000..6e21f01 --- /dev/null +++ b/src/main/scala/venix/hookla/sources/github/events/PingEvent.scala @@ -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 + } +} diff --git a/src/main/scala/venix/hookla/sources/github/events/PushEvent.scala b/src/main/scala/venix/hookla/sources/github/events/PushEvent.scala new file mode 100644 index 0000000..c8a2fb2 --- /dev/null +++ b/src/main/scala/venix/hookla/sources/github/events/PushEvent.scala @@ -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 + } +} diff --git a/src/main/scala/venix/hookla/sources/package.scala b/src/main/scala/venix/hookla/sources/package.scala new file mode 100644 index 0000000..5d3f2ff --- /dev/null +++ b/src/main/scala/venix/hookla/sources/package.scala @@ -0,0 +1,3 @@ +package venix.hookla + +package object sources {}