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

(example): Provide example application #27

Merged
merged 21 commits into from
Dec 23, 2022
Merged
Show file tree
Hide file tree
Changes from 20 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
20 changes: 15 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,19 @@ lazy val root =
lazy val library =
project
.in(file("modules/library"))
.disablePlugins(RevolverPlugin)
.settings(stdSettings("zio-elasticsearch"))
.settings(scalacOptions += "-language:higherKinds")
.configs(IntegrationTest)
.settings(
Defaults.itSettings,
libraryDependencies ++= List(
"com.softwaremill.sttp.client3" %% "zio" % "3.8.3",
"com.softwaremill.sttp.client3" %% "zio-json" % "3.8.3",
"com.softwaremill.sttp.client3" %% "zio" % "3.8.5",
"com.softwaremill.sttp.client3" %% "zio-json" % "3.8.5",
"dev.zio" %% "zio-json" % "0.3.0",
"dev.zio" %% "zio-prelude" % "1.0.0-RC16",
"dev.zio" %% "zio-schema" % "0.3.1",
"dev.zio" %% "zio-schema-json" % "0.3.1",
"dev.zio" %% "zio-schema" % "0.4.1",
"dev.zio" %% "zio-schema-json" % "0.4.1",
"org.apache.commons" % "commons-lang3" % "3.12.0",
"dev.zio" %% "zio-test" % "2.0.5" % Tests,
"dev.zio" %% "zio-test-sbt" % "2.0.5" % Tests
Expand All @@ -51,9 +52,17 @@ lazy val example =
project
.in(file("modules/example"))
.settings(stdSettings("example"))
.settings(scalacOptions += "-language:higherKinds")
.settings(
libraryDependencies ++= List(
"dev.zio" %% "zio" % "2.0.5"
"dev.zio" %% "zio" % "2.0.5",
"dev.zio" %% "zio-config" % "3.0.6",
"dev.zio" %% "zio-config-magnolia" % "3.0.6",
"dev.zio" %% "zio-config-typesafe" % "3.0.6",
"dev.zio" %% "zio-http" % "0.0.3",
"dev.zio" %% "zio-json" % "0.3.0",
"dev.zio" %% "zio-schema" % "0.4.1",
"dev.zio" %% "zio-schema-json" % "0.4.1"
)
)
.dependsOn(library)
Expand All @@ -65,6 +74,7 @@ lazy val docs =
project
.in(file("modules/docs"))
.enablePlugins(MdocPlugin, DocusaurusPlugin, ScalaUnidocPlugin)
.disablePlugins(RevolverPlugin)
.dependsOn(library)
.settings(
publish / skip := true,
Expand Down
22 changes: 22 additions & 0 deletions modules/example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# ZIO Elasticsearch Example Application

This application represents an example of usage `zio-elasticsearch` library for **Elasticsearch 7.x**.

### Running

- Run the Elasticsearch 7.x service (one can be found as part of the `docker-compose.yml` file in the root of this
repository)
- Start the application by running the following command:
```shell
./sbt "~example/reStart"
```
- Check whether the application is running [here](http://localhost:9000/health)
- Explore endpoints using Postman collection (`zio-elasticsearch-example.postman_collection.json`)

### Description

On the application startup - a **"repositories"** index will be deleted, and immediately re-created with the mapping
definition given in the `resources/mapping.json` file.

After successfully starting the application, you can test the exposed ZIO Elasticsearch library's API through exposed HTTP
endpoints.
8 changes: 8 additions & 0 deletions modules/example/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
http {
port = 9000
}

elasticsearch {
host = "localhost"
port = 9200
}
38 changes: 38 additions & 0 deletions modules/example/src/main/resources/mapping.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"settings": {
"index": {
"number_of_shards": 1
}
},
"mappings": {
"_routing": {
"required": true
},
"properties": {
"id": {
"type": "keyword"
},
"organization": {
"type": "keyword"
},
"name": {
"type": "keyword"
},
"url": {
"type": "keyword"
},
"description": {
"type": "text"
},
"lastCommitAt": {
"type": "date"
},
"stars": {
"type": "integer"
},
"forks": {
"type": "integer"
}
}
}
}
23 changes: 23 additions & 0 deletions modules/example/src/main/scala/example/GitHubRepo.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package example

import zio.json.{DeriveJsonEncoder, JsonEncoder}
import zio.schema.{DeriveSchema, Schema}

import java.time.LocalDateTime

final case class GitHubRepo(
id: Option[String],
organization: String,
name: String,
url: String,
description: Option[String],
lastCommitAt: LocalDateTime,
stars: Int,
forks: Int
)

object GitHubRepo {
implicit val schema: Schema[GitHubRepo] = DeriveSchema.gen[GitHubRepo]

implicit val encoder: JsonEncoder[GitHubRepo] = DeriveJsonEncoder.gen[GitHubRepo]
}
58 changes: 58 additions & 0 deletions modules/example/src/main/scala/example/Main.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package example

import example.api.{HealthCheck, Repositories}
import example.config.{AppConfig, ElasticsearchConfig, HttpConfig}
import sttp.client3.httpclient.zio.HttpClientZioBackend
import zio._
import zio.config.getConfig
import zio.elasticsearch.{ElasticConfig, ElasticExecutor, ElasticRequest}
import zio.http.{Server, ServerConfig}

import scala.io.Source

object Main extends ZIOAppDefault {

override def run: Task[ExitCode] = {
val elasticConfigLive = ZLayer(getConfig[ElasticsearchConfig].map(es => ElasticConfig(es.host, es.port)))

(prepare *> runServer).provide(
AppConfig.live,
elasticConfigLive,
ElasticExecutor.live,
HttpClientZioBackend.layer()
)
}

private[this] def prepare: RIO[ElasticExecutor, Unit] = {
val deleteIndex: RIO[ElasticExecutor, Unit] =
for {
_ <- ZIO.logInfo(s"Deleting index '$Index'...")
_ <- ElasticRequest.deleteIndex(Index).execute
} yield ()

val createIndex: RIO[ElasticExecutor, Unit] =
for {
_ <- ZIO.logInfo(s"Creating index '$Index'...")
mapping <- ZIO.attempt(Source.fromURL(getClass.getResource("/mapping.json")).mkString)
_ <- ElasticRequest.createIndex(Index, Some(mapping)).execute
} yield ()

deleteIndex *> createIndex
}

private[this] def runServer: RIO[HttpConfig with ElasticExecutor, ExitCode] = {
val serverConfigLive = ZLayer(getConfig[HttpConfig].map(http => ServerConfig.default.port(http.port)))

(for {
http <- getConfig[HttpConfig]
_ <- ZIO.logInfo(s"Starting an HTTP service on port: ${http.port}")
routes = HealthCheck.Route ++ Repositories.Routes
_ <- Server.serve(routes)
} yield ExitCode.success).provideSome(
RepositoriesElasticsearch.live,
Server.live,
serverConfigLive
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package example

import zio.elasticsearch.ElasticError.DocumentRetrievingError.DocumentNotFound
import zio.elasticsearch.{DocumentId, ElasticExecutor, ElasticRequest, Routing}
import zio.{RIO, Task, URLayer, ZIO, ZLayer}

final case class RepositoriesElasticsearch(executor: ElasticExecutor) {

def findById(organization: String, id: String): Task[Option[GitHubRepo]] =
for {
routing <- Routing.make(organization).toZIO.mapError(e => new IllegalArgumentException(e))
req = ElasticRequest.getById[GitHubRepo](Index, DocumentId(id)).routing(routing)
res <- executor.execute(req)
} yield res.toOption

def create(repository: GitHubRepo): Task[Option[DocumentId]] =
for {
routing <- Routing.make(repository.organization).toZIO.mapError(e => new IllegalArgumentException(e))
req = ElasticRequest.create(Index, repository).routing(routing)
res <- executor.execute(req)
} yield res

def upsert(id: String, repository: GitHubRepo): Task[Unit] =
for {
routing <- Routing.make(repository.organization).toZIO.mapError(e => new IllegalArgumentException(e))
req = ElasticRequest.upsert(Index, DocumentId(id), repository).routing(routing)
_ <- executor.execute(req)
} yield ()

def remove(organization: String, id: String): Task[Either[DocumentNotFound.type, Unit]] =
for {
routing <- Routing.make(organization).toZIO.mapError(e => new IllegalArgumentException(e))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe we could extract this to method makeRouting, because this code is repeated in every method.

req = ElasticRequest.deleteById(Index, DocumentId(id)).routing(routing)
res <- executor.execute(req)
} yield res

}

object RepositoriesElasticsearch {

def findById(organization: String, id: String): RIO[RepositoriesElasticsearch, Option[GitHubRepo]] =
ZIO.serviceWithZIO[RepositoriesElasticsearch](_.findById(organization, id))

def create(repository: GitHubRepo): RIO[RepositoriesElasticsearch, Option[DocumentId]] =
ZIO.serviceWithZIO[RepositoriesElasticsearch](_.create(repository))

def upsert(id: String, repository: GitHubRepo): RIO[RepositoriesElasticsearch, Unit] =
ZIO.serviceWithZIO[RepositoriesElasticsearch](_.upsert(id, repository))

def remove(organization: String, id: String): RIO[RepositoriesElasticsearch, Either[DocumentNotFound.type, Unit]] =
ZIO.serviceWithZIO[RepositoriesElasticsearch](_.remove(organization, id))

lazy val live: URLayer[ElasticExecutor, RepositoriesElasticsearch] =
ZLayer.fromFunction(RepositoriesElasticsearch(_))
}
13 changes: 13 additions & 0 deletions modules/example/src/main/scala/example/api/HealthCheck.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package example.api

import zio.http._
import zio.http.model.Method
import zio.json.ast.Json._

object HealthCheck {

final val Route: Http[Any, Nothing, Any, Response] = Http.collect { case Method.GET -> !! / "health" =>
Response.json(Obj("name" -> Str("zio-elasticsearch-example"), "status" -> Str("up")).toJson)
}

}
86 changes: 86 additions & 0 deletions modules/example/src/main/scala/example/api/Repositories.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package example.api

import example.{GitHubRepo, RepositoriesElasticsearch}
import zio.ZIO
import zio.elasticsearch.DocumentId
import zio.http._
import zio.http.model.Method
import zio.http.model.Status._
import zio.json.EncoderOps
import zio.schema.codec.JsonCodec

object Repositories {

private final val BasePath = !! / "api" / "repositories"

final val Routes: Http[RepositoriesElasticsearch, Nothing, Request, Response] =
Http.collectZIO[Request] {
case Method.GET -> BasePath =>
ZIO.succeed(Response.text("TODO: Get a list of repositories").setStatus(NotImplemented))

case Method.GET -> BasePath / organization / id =>
RepositoriesElasticsearch
.findById(organization, id)
.map {
case Some(r) =>
Response.json(r.toJson)
case None =>
Response.json(ErrorResponse.fromReasons(s"Repository $id does not exist.").toJson).setStatus(NotFound)
}
.orDie

case req @ Method.POST -> BasePath =>
req.body.asString
.map(JsonCodec.JsonDecoder.decode[GitHubRepo](GitHubRepo.schema, _))
.flatMap {
case Left(e) =>
ZIO.succeed(Response.json(ErrorResponse.fromReasons(e.message).toJson).setStatus(BadRequest))
case Right(repo) =>
RepositoriesElasticsearch.create(repo).map {
case Some(id) =>
Response.json(repo.copy(id = Some(DocumentId.unwrap(id))).toJson).setStatus(Created)
case None =>
Response.json(ErrorResponse.fromReasons("Failed to create repository.").toJson).setStatus(BadRequest)
}
}
.orDie

case req @ Method.PUT -> BasePath / id =>
req.body.asString
.map(JsonCodec.JsonDecoder.decode[GitHubRepo](GitHubRepo.schema, _))
.flatMap {
case Left(e) =>
ZIO.succeed(Response.json(ErrorResponse.fromReasons(e.message).toJson).setStatus(BadRequest))
case Right(repo) if repo.id.exists(_ != id) =>
ZIO.succeed(
Response
.json(
ErrorResponse.fromReasons("The ID provided in the path does not match the ID from the body.").toJson
)
.setStatus(BadRequest)
)
case Right(repo) =>
(RepositoriesElasticsearch
.upsert(id, repo.copy(id = Some(id))) *> RepositoriesElasticsearch.findById(
repo.organization,
id
)).map {
case Some(updated) => Response.json(updated.toJson)
case None => Response.json(ErrorResponse.fromReasons("Operation failed.").toJson).setStatus(BadRequest)
}
}
.orDie

case Method.DELETE -> BasePath / organization / id =>
RepositoriesElasticsearch
.remove(organization, id)
.map {
case Right(_) =>
Response.status(NoContent)
case Left(_) =>
Response.json(ErrorResponse.fromReasons(s"Repository $id does not exist.").toJson).setStatus(NotFound)
}
.orDie
}

}
23 changes: 23 additions & 0 deletions modules/example/src/main/scala/example/api/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package example

import zio.Chunk
import zio.json._

package object api {

final case class ErrorResponseData(body: Chunk[String])

object ErrorResponseData {
implicit val encoder: JsonEncoder[ErrorResponseData] = DeriveJsonEncoder.gen[ErrorResponseData]
}

final case class ErrorResponse(errors: ErrorResponseData)

object ErrorResponse {
implicit val encoder: JsonEncoder[ErrorResponse] = DeriveJsonEncoder.gen[ErrorResponse]

def fromReasons(reasons: String*): ErrorResponse =
new ErrorResponse(ErrorResponseData(Chunk.fromIterable(reasons)))
}

}
Loading