Skip to content

Commit

Permalink
feat: tapir on search-api (#17)
Browse files Browse the repository at this point in the history
* feat: Tapir for defining the Search APIs

* feat: glue code to make Tapir work with Borer

* feat: swagger & OpenAPI docs endpoints
  • Loading branch information
jachro authored Feb 9, 2024
1 parent 3270e18 commit d1d7d8c
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 41 deletions.
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
)
}

0 comments on commit d1d7d8c

Please sign in to comment.