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

feat: tapir on search-api #17

Merged
merged 3 commits into from
Feb 9, 2024
Merged
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
12 changes: 7 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ lazy val http4sBorer = project
description := "Use borer codecs with http4s",
libraryDependencies ++=
Dependencies.borer ++
Dependencies.fs2Core ++
Dependencies.http4sCore ++
Dependencies.fs2Core
Dependencies.tapirCore
)

lazy val httpClient = project
Expand Down Expand Up @@ -255,14 +256,15 @@ lazy val searchApi = project
.settings(
name := "search-api",
libraryDependencies ++=
Dependencies.http4sDsl ++
Dependencies.ciris ++
Dependencies.http4sDsl ++
Dependencies.http4sServer ++
Dependencies.ciris
Dependencies.tapirHttp4sServer ++
Dependencies.tapirOpenAPi
)
.dependsOn(
commons % "compile->compile;test->test",
messages % "compile->compile;test->test",
http4sAvro % "compile->compile;test->test",
http4sBorer % "compile->compile;test->test",
searchSolrClient % "compile->compile;test->test",
configValues % "compile->compile;test->test"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.renku.search.http.borer

import io.bullet.borer.{Borer, Decoder, Encoder, Json}
import sttp.tapir.DecodeResult.Error.{JsonDecodeException, JsonError}
import sttp.tapir.DecodeResult.{Error, Value}
import sttp.tapir.{Codec, CodecFormat, DecodeResult, EndpointIO, RawBodyType, Schema}

trait TapirBorerJson:

def borerJsonBody[T: Encoder: Decoder: Schema]: EndpointIO.Body[Array[Byte], T] =
jsonBodyAnyFormat(borerCodec[T])

private type BorerJsonCodec[T] = Codec[Array[Byte], T, CodecFormat.Json]

private def jsonBodyAnyFormat[T](
codec: BorerJsonCodec[T]
): EndpointIO.Body[Array[Byte], T] =
EndpointIO.Body(RawBodyType.ByteArrayBody, codec, EndpointIO.Info.empty)

private def borerCodec[T: Encoder: Decoder: Schema]: BorerJsonCodec[T] =
new Codec[Array[Byte], T, CodecFormat.Json]:

override def rawDecode(l: Array[Byte]): DecodeResult[T] =
Json
.decode(l)
.to[T]
.valueEither
.fold(borerErrorToTapir, Value.apply)

private def borerErrorToTapir[IP](be: Borer.Error[IP]): Error =
Error(
original = be.getMessage,
error = JsonDecodeException(
errors = List(JsonError(be.getMessage, Nil)),
underlying = be
)
)

override def encode(h: T): Array[Byte] =
Json.encode(h).toByteArray

override lazy val schema: Schema[T] = implicitly[Schema[T]]

override lazy val format: CodecFormat.Json = CodecFormat.Json()
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ class RedisQueueClient[F[_]: Async: Log](client: RedisClient) extends QueueClien
ByteVector.encodeUtf8(encoding.name).fold(throw _, identity)

private def decodeEncoding(encoding: ByteVector): Encoding =
encoding.decodeUtf8.map(Encoding.valueOf).fold(throw _, identity)
encoding.decodeUtf8
.map(_.toLowerCase.capitalize)
.map(Encoding.valueOf)
.fold(throw _, identity)

override def acquireEventsStream(
queueName: QueueName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,56 @@

package io.renku.search.api

import cats.Monad
import cats.effect.{Async, Resource}
import cats.syntax.all.*
import fs2.io.net.Network
import io.renku.search.api.Project.given
import io.renku.search.http.borer.TapirBorerJson
import io.renku.solr.client.SolrConfig
import org.http4s.dsl.Http4sDsl
import org.http4s.server.Router
import org.http4s.{HttpApp, HttpRoutes, Request, Response}
import org.http4s.{HttpApp, HttpRoutes, Response}
import sttp.tapir.*
import sttp.tapir.server.ServerEndpoint
import sttp.tapir.server.http4s.Http4sServerInterpreter
import sttp.tapir.swagger.bundle.SwaggerInterpreter

object HttpApplication:
def apply[F[_]: Async: Network](
solrConfig: SolrConfig
): Resource[F, HttpApp[F]] =
SearchApi[F](solrConfig).map(new HttpApplication[F](_).router)

class HttpApplication[F[_]: Monad](searchApi: SearchApi[F]) extends Http4sDsl[F]:
class HttpApplication[F[_]: Async](searchApi: SearchApi[F])
extends Http4sDsl[F]
with TapirBorerJson:

lazy val router: HttpApp[F] =
Router[F]("/" -> routes).orNotFound

private lazy val routes: HttpRoutes[F] = HttpRoutes.of[F] {
case GET -> Root / "api" / phrase => searchApi.find(phrase)
case GET -> Root / "ping" => Ok("pong")
}
private lazy val routes: HttpRoutes[F] =
Http4sServerInterpreter[F]().toRoutes(endpoints ::: swaggerEndpoints)

private lazy val endpoints: List[ServerEndpoint[Any, F]] =
List(
searchEndpoint.serverLogic(searchApi.find),
pingEndpoint.serverLogic[F](_ => "pong".asRight[Unit].pure[F])
)

private lazy val searchEndpoint: PublicEndpoint[String, String, List[Project], Any] =
val query =
path[String].name("user query").description("User defined query e.g. renku~")
endpoint.get
.in("api" / query)
.errorOut(borerJsonBody[String])
.out(borerJsonBody[List[Project]])
.description("Search API for searching Renku entities")

private lazy val pingEndpoint: PublicEndpoint[Unit, Unit, String, Any] =
endpoint.get
.in("ping")
.out(stringBody)
.description("Ping")

private lazy val swaggerEndpoints =
SwaggerInterpreter().fromServerEndpoints[F](endpoints, "Search API", "0.0.1")
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2024 Swiss Data Science Center (SDSC)
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
* Eidgenössische Technische Hochschule Zürich (ETHZ).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.renku.search.api

import io.bullet.borer.derivation.MapBasedCodecs.{deriveDecoder, deriveEncoder}
import io.bullet.borer.{Decoder, Encoder}
import sttp.tapir.Schema
import sttp.tapir.generic.auto.schemaForCaseClass

final case class Project(id: String, name: String, description: String)

object Project:
given Encoder[Project] = deriveEncoder
given Decoder[Project] = deriveDecoder
given Schema[Project] = Schema.derivedSchema[Project]
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@ import cats.effect.{Async, Resource}
import fs2.io.net.Network
import io.renku.search.solr.client.SearchSolrClient
import io.renku.solr.client.SolrConfig
import org.http4s.Response

trait SearchApi[F[_]]:
def find(phrase: String): F[Response[F]]
def find(phrase: String): F[Either[String, List[Project]]]

object SearchApi:
def apply[F[_]: Async: Network](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,8 @@ package io.renku.search.api

import cats.effect.Async
import cats.syntax.all.*
import io.renku.api.Project as ApiProject
import io.renku.avro.codec.all.given
import io.renku.avro.codec.json.AvroJsonEncoder
import io.renku.search.http.avro.AvroEntityCodec.Implicits.entityEncoder
import io.renku.search.solr.client.SearchSolrClient
import io.renku.search.solr.documents.Project as SolrProject
import org.http4s.Response
import org.http4s.dsl.Http4sDsl
import scribe.Scribe

Expand All @@ -36,21 +31,22 @@ private class SearchApiImpl[F[_]: Async](solrClient: SearchSolrClient[F])

private given Scribe[F] = scribe.cats[F]

override def find(phrase: String): F[Response[F]] =
override def find(phrase: String): F[Either[String, List[Project]]] =
solrClient
.findProjects(phrase)
.map(toApiModel)
.flatMap(Ok(_))
.map(_.asRight[String])
.handleErrorWith(errorResponse(phrase))

private given AvroJsonEncoder[List[ApiProject]] =
AvroJsonEncoder.encodeList[ApiProject](ApiProject.SCHEMA$)

private def errorResponse(phrase: String): Throwable => F[Response[F]] =
private def errorResponse(
phrase: String
): Throwable => F[Either[String, List[Project]]] =
err =>
val message = s"Finding by '$phrase' phrase failed"
Scribe[F]
.error(s"Finding by '$phrase' phrase failed", err)
.map(_ => Response[F](InternalServerError).withEntity(err.getMessage))
.error(message, err)
.as(message)
.map(_.asLeft[List[Project]])

private def toApiModel(entities: List[SolrProject]): List[ApiProject] =
entities.map(p => ApiProject(p.id, p.name, p.description))
private def toApiModel(entities: List[SolrProject]): List[Project] =
entities.map(p => Project(p.id, p.name, p.description))
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@
package io.renku.search.api

import cats.effect.IO
import io.renku.api.Project as ApiProject
import io.renku.avro.codec.AvroDecoder
import io.renku.avro.codec.all.given
import io.renku.avro.codec.json.AvroJsonDecoder
import io.renku.search.http.avro.AvroEntityCodec.given
import io.renku.search.solr.client.SearchSolrClientGenerators.*
import io.renku.search.solr.client.SearchSolrSpec
import io.renku.search.solr.documents.Project as SolrProject
Expand All @@ -41,13 +36,11 @@ class SearchApiSpec extends CatsEffectSuite with SearchSolrSpec:
val searchApi = new SearchApiImpl[IO](client)
for {
_ <- client.insertProjects(project1 :: project2 :: Nil)
response <- searchApi.find("matching")
results <- response.as[List[ApiProject]]
results <- searchApi
.find("matching")
.map(_.fold(err => fail(s"Calling Search API failed with $err"), identity))
} yield assert(results contains toApiProject(project1))
}

private given AvroJsonDecoder[List[ApiProject]] =
AvroJsonDecoder.decodeList(ApiProject.SCHEMA$)

private def toApiProject(project: SolrProject) =
ApiProject(project.id, project.name, project.description)
Project(project.id, project.name, project.description)
11 changes: 11 additions & 0 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ object Dependencies {
val scodec = "2.2.2"
val scodecBits = "1.1.38"
val scribe = "3.13.0"
val tapir = "1.9.9"
}

val ciris = Seq(
Expand Down Expand Up @@ -98,4 +99,14 @@ object Dependencies {
val scalacheckEffectMunit = Seq(
"org.typelevel" %% "scalacheck-effect-munit" % V.scalacheckEffectMunit
)

val tapirCore = Seq(
"com.softwaremill.sttp.tapir" %% "tapir-core" % V.tapir
)
val tapirHttp4sServer = Seq(
"com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % V.tapir
)
val tapirOpenAPi = Seq(
"com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % V.tapir
)
}
Loading