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: a simple service exposing Search API #8

Merged
merged 10 commits into from
Jan 30, 2024
38 changes: 37 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ lazy val root = project
redisClient,
solrClient,
searchSolrClient,
searchProvision
searchProvision,
searchApi
)

lazy val commons = project
Expand Down Expand Up @@ -178,6 +179,23 @@ lazy val avroCodec = project
Dependencies.scodecBits
)

lazy val http4sAvro = project
.in(file("modules/http4s-avro"))
.enablePlugins(AutomateHeaderPlugin)
.disablePlugins(DbTestPlugin)
.withId("http4s-avro")
.settings(commonSettings)
.settings(
name := "http4s-avro",
description := "Avro codecs for http4s",
libraryDependencies ++=
Dependencies.http4sCore ++
Dependencies.fs2Core
)
.dependsOn(
avroCodec % "compile->compile;test->test"
)

lazy val messages = project
.in(file("modules/messages"))
.settings(commonSettings)
Expand Down Expand Up @@ -206,6 +224,24 @@ lazy val searchProvision = project
)
.enablePlugins(AutomateHeaderPlugin)

lazy val searchApi = project
.in(file("modules/search-api"))
.withId("search-api")
.settings(commonSettings)
.settings(
name := "search-api",
libraryDependencies ++=
Dependencies.http4sDsl ++
Dependencies.http4sServer
)
.dependsOn(
commons % "compile->compile;test->test",
messages % "compile->compile;test->test",
http4sAvro % "compile->compile;test->test",
searchSolrClient % "compile->compile;test->test"
)
.enablePlugins(AutomateHeaderPlugin)

lazy val commonSettings = Seq(
organization := "io.renku",
publish / skip := true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.renku.avro.codec.json

import cats.syntax.all.*
import io.renku.avro.codec.{AvroDecoder, AvroReader}
import org.apache.avro.Schema
import scodec.bits.ByteVector
Expand All @@ -19,8 +20,11 @@ object AvroJsonDecoder:
def apply[A](f: ByteVector => Either[String, A]): AvroJsonDecoder[A] =
(json: ByteVector) => f(json)

def create[A: AvroDecoder](schema: Schema): AvroJsonDecoder[A] = { json =>
Try(AvroReader(schema).readJson[A](json)).toEither.left
.map(_.getMessage)
def create[A: AvroDecoder](schema: Schema): AvroJsonDecoder[A] = json =>
Try(AvroReader(schema).readJson[A](json)).toEither
.leftMap(_.getMessage)
.flatMap(_.headOption.toRight(s"Empty json"))
}

def decodeList[A: AvroDecoder](schema: Schema): AvroJsonDecoder[List[A]] = json =>
Try(AvroReader(schema).readJson[A](json)).toEither
.bimap(_.getMessage, _.toList)
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.renku.avro.codec.json

import io.renku.avro.codec.{AvroEncoder, AvroWriter}
import io.renku.avro.codec.encoders.all.given
import io.renku.avro.codec.{AvroEncoder, AvroWriter}
import org.apache.avro.{Schema, SchemaBuilder}
import scodec.bits.ByteVector

Expand All @@ -17,7 +17,10 @@ object AvroJsonEncoder:
(a: A) => f(a)

def create[A: AvroEncoder](schema: Schema): AvroJsonEncoder[A] =
a => AvroWriter(schema).writeJson(Seq(a))
encodeList(schema).contramap(List(_))

def encodeList[A: AvroEncoder](schema: Schema): AvroJsonEncoder[List[A]] =
AvroWriter(schema).writeJson(_)

given AvroJsonEncoder[String] =
create[String](SchemaBuilder.builder().stringType())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import io.renku.avro.codec.encoders.all.given
import munit.FunSuite
import org.apache.avro.SchemaBuilder

class AvroReaderTest extends FunSuite {
class AvroReaderSpec extends FunSuite {

case class Foo(name: String, age: Int) derives AvroDecoder, AvroEncoder
val fooSchema = SchemaBuilder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,36 @@ import io.renku.avro.codec.*
import io.renku.avro.codec.all.given
import munit.FunSuite
import org.apache.avro.{Schema, SchemaBuilder}

import scala.collection.immutable.Map

class JsonCodecTest extends FunSuite {
class JsonCodecSpec extends FunSuite {

test("encode and decode json") {
test("encode and decode json"):
val person =
JsonCodecTest.Person("hugo", 42, Map("date" -> "1982", "children" -> "0"))
val json = AvroJsonEncoder[JsonCodecTest.Person].encode(person)
val decoded = AvroJsonDecoder[JsonCodecTest.Person].decode(json)
JsonCodecSpec.Person("hugo", 42, Map("date" -> "1982", "children" -> "0"))
val json = AvroJsonEncoder[JsonCodecSpec.Person].encode(person)
val decoded = AvroJsonDecoder[JsonCodecSpec.Person].decode(json)
assertEquals(decoded, Right(person))
}

test("encode and decode lists"):
val person1 =
JsonCodecSpec.Person("hugo1", 41, Map("date" -> "1981", "children" -> "1"))
val person2 =
JsonCodecSpec.Person("hugo1", 42, Map("date" -> "1982", "children" -> "2"))
val persons = person1 :: person2 :: Nil

val json = AvroJsonEncoder
.encodeList[JsonCodecSpec.Person](JsonCodecSpec.Person.schema)
.encode(persons)
val decoded = AvroJsonDecoder
.decodeList[JsonCodecSpec.Person](JsonCodecSpec.Person.schema)
.decode(json)

assertEquals(decoded, Right(persons))
}

object JsonCodecTest:
object JsonCodecSpec:

case class Person(name: String, age: Int, props: Map[String, String])
derives AvroEncoder,
Expand Down
3 changes: 3 additions & 0 deletions modules/http4s-avro/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# http4s-avro

This module contains tooling to bridge avro and http4s.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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.avro

import cats.data.EitherT
import cats.effect.Async
import cats.syntax.all.*
import fs2.Chunk
import io.renku.avro.codec.json.{AvroJsonDecoder, AvroJsonEncoder}
import org.http4s.MediaType.application
import org.http4s.headers.`Content-Type`
import org.http4s.{DecodeFailure, DecodeResult, EntityDecoder, EntityEncoder, MalformedMessageBodyFailure, Media, MediaType}
import scodec.bits.ByteVector

object AvroEntityCodec extends AvroEntityCodec:
export Implicits.*

trait AvroEntityCodec:

def decodeEntity[F[_]: Async, A: AvroJsonDecoder]: EntityDecoder[F, A] =
EntityDecoder.decodeBy(MediaType.application.json)(decodeJson)

private def decodeJson[F[_]: Async, A: AvroJsonDecoder](
media: Media[F]
): DecodeResult[F, A] =
EitherT {
media.body.compile
.to(ByteVector)
.map(decodeAvro)
}

private def decodeAvro[A: AvroJsonDecoder]: ByteVector => Either[DecodeFailure, A] =
AvroJsonDecoder[A]
.decode(_)
.leftMap(err =>
MalformedMessageBodyFailure(s"Cannot decode Json Avro message: $err")
)

def encodeEntity[F[_], A: AvroJsonEncoder]: EntityEncoder[F, A] =
EntityEncoder.simple(`Content-Type`(application.json))(a =>
Chunk.byteVector(AvroJsonEncoder[A].encode(a))
)

trait Implicits:

implicit def entityDecoder[F[_]: Async, A: AvroJsonDecoder]: EntityDecoder[F, A] =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can now use given:) (I might forgot to change that myself when I used code from somewhere else:))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I used metrics collector as a template ;)

decodeEntity[F, A]

implicit def entityEncoder[F[_], A: AvroJsonEncoder]: EntityEncoder[F, A] =
encodeEntity[F, A]

object Implicits extends Implicits
10 changes: 10 additions & 0 deletions modules/messages/src/main/avro/api.avdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@namespace("io.renku.api")
protocol ApiShapes {

/* An example record for a Project Entity returned from the Search API */
record Project {
string id;
string name;
string description;
}
}
12 changes: 0 additions & 12 deletions modules/messages/src/main/avro/messages.avdl
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,4 @@ protocol Messages {
string name;
timestamp_ms deletedAt;
}


/* record ProjectMsg {
union { ProjectCreated, ProjectUpdated, ProjectDeleted } message;
} */

/* An example record for a Project Entity returned from the Search API */
record ProjectEntity {
string id;
string name;
string description;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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.redis.client.util

import cats.effect.{ExitCode, IO, IOApp}
import io.renku.servers.RedisServer

/** This is a utility to start a Redis server for manual testing */
object TestSearchRedisServer extends IOApp:

override def run(args: List[String]): IO[ExitCode] =
(IO(RedisServer.start()) >> IO.never[ExitCode]).as(ExitCode.Success)
3 changes: 3 additions & 0 deletions modules/search-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# search-api

This module exposes the Search API.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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 cats.Monad
import cats.effect.{Async, Resource}
import fs2.io.net.Network
import io.renku.solr.client.SolrConfig
import org.http4s.dsl.Http4sDsl
import org.http4s.server.Router
import org.http4s.{HttpApp, HttpRoutes, Request, Response}
import scribe.Scribe

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

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

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")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* 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 cats.effect.{Async, Resource}
import com.comcast.ip4s.{Port, ipv4, port}
import fs2.io.net.Network
import org.http4s.HttpApp
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.server.Server

object HttpServer:

val port: Port = port"8080"

def build[F[_]: Async: Network](app: HttpApp[F]): Resource[F, Server] =
EmberServerBuilder
.default[F]
.withHost(ipv4"0.0.0.0")
.withPort(port)
.withHttpApp(app)
.build
Loading
Loading