Initial implementation

master
Andriy Kushnir (Orhideous) 2019-12-22 14:12:41 +02:00
parent 92f6827f17
commit 9f04b9bdd2
No known key found for this signature in database
GPG Key ID: 62E078AB621B0D15
11 changed files with 260 additions and 0 deletions

48
.scalafmt.conf Normal file
View File

@ -0,0 +1,48 @@
version = 2.0.0
maxColumn = 120
style = defaultWithAlign
docstrings = ScalaDoc
assumeStandardLibraryStripMargin = true
continuationIndent {
callSite = 2
defnSite = 2
}
align = more
align.tokens.add = [{code = ":", owner = "Term.Param"}]
align {
arrowEnumeratorGenerator = true
openParenCallSite = true
openParenDefnSite = false
}
newlines {
alwaysBeforeTopLevelStatements = true
alwaysBeforeElseAfterCurlyIf = false
}
danglingParentheses = true
rewrite {
rules = [
RedundantBraces
RedundantParens
SortModifiers
PreferCurlyFors
ExpandImportSelectors
]
sortModifiers.order = [
"override", "private", "protected", "final", "implicit", "sealed", "abstract", "lazy"
]
redundantBraces.stringInterpolation = true
}
includeCurlyBraceInSelectChains = true
optIn.breakChainOnFirstMethodDot = true
verticalMultilineAtDefinitionSiteArityThreshold = 5
verticalMultiline.arityThreshold = 5
verticalMultiline.atDefnSite = true
verticalMultiline.newlineBeforeImplicitKW = false
project.git = true

37
build.sbt Normal file
View File

@ -0,0 +1,37 @@
val Http4sVersion = "0.20.8"
val CirceVersion = "0.11.1"
val LogbackVersion = "1.2.3"
val BetterFilesVersion = "3.8.0"
val DirectoryWatcherVersion = "0.9.6"
val ScalaLoggingVersion = "3.9.2"
val root = (project in file("."))
.settings(
organization := "name.orhideous",
name := "twicher",
version := "0.1.0-SNAPSHOT",
scalaVersion := "2.12.8",
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-blaze-server" % Http4sVersion,
"org.http4s" %% "http4s-circe" % Http4sVersion,
"org.http4s" %% "http4s-dsl" % Http4sVersion,
"io.circe" %% "circe-generic" % CirceVersion,
"com.github.pathikrit" %% "better-files" % BetterFilesVersion,
"io.methvin" %% "directory-watcher-better-files" % DirectoryWatcherVersion,
"com.typesafe.scala-logging" %% "scala-logging" % ScalaLoggingVersion,
"ch.qos.logback" % "logback-classic" % LogbackVersion
),
addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.10.3"),
addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.0")
)
scalacOptions ++= Seq(
"-deprecation",
"-encoding",
"UTF-8",
"-language:higherKinds",
"-language:postfixOps",
"-feature",
"-Ypartial-unification",
"-Xfatal-warnings"
)

3
project/build.properties Normal file
View File

@ -0,0 +1,3 @@
# suppress inspection "UnusedProperty" for whole file
sbt.version=1.3.5

2
project/plugins.sbt Normal file
View File

@ -0,0 +1,2 @@
addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.3")
addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")

View File

@ -0,0 +1,10 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>[%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@ -0,0 +1,9 @@
package name.orhideous.twicher
sealed trait Error extends Exception
object Error {
case object NoSuchQuote extends Error
}

View File

@ -0,0 +1,29 @@
package name.orhideous.twicher
import cats.effect.ExitCode
import cats.effect.IO
import cats.effect.IOApp
import cats.implicits._
import name.orhideous.twicher.persistence.QuotesFileRepository
import org.http4s.implicits._
import org.http4s.server.Router
import org.http4s.server.blaze._
object Main extends IOApp {
private val host = sys.env.getOrElse("TWICHER_HOST", "0.0.0.0")
private val port = sys.env.getOrElse("TWICHER_PORT", "9000").toInt
private val dir = sys.env.getOrElse("TWICHER_DIR", "./data")
private val repository = QuotesFileRepository(dir)
private val routes = Router[IO]("/" -> Routes(repository)).orNotFound
override def run(args: List[String]): IO[ExitCode] =
BlazeServerBuilder[IO]
.bindHttp(port, host)
.withHttpApp(routes)
.serve
.compile
.drain
.as(ExitCode.Success)
}

View File

@ -0,0 +1,29 @@
package name.orhideous.twicher
import cats.effect.IO
import io.circe.generic.auto._
import name.orhideous.twicher.persistence.QuotesRepository
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityCodec._
import org.http4s.dsl.Http4sDsl
object Routes {
def apply(repository: QuotesRepository): HttpRoutes[IO] = {
val dsl = new Http4sDsl[IO] {}
import dsl._
HttpRoutes.of[IO] {
case GET -> Root =>
repository.list.flatMap(Ok(_))
case GET -> Root / "random" =>
repository.random.flatMap(Ok(_))
case GET -> Root / IntVar(id) =>
repository.read(id).flatMap(Ok(_))
}
}
}

View File

@ -0,0 +1,3 @@
package name.orhideous.twicher.persistence
case class Quote(id: Int, text: String)

View File

@ -0,0 +1,78 @@
package name.orhideous.twicher.persistence
import java.nio.charset.Charset
import java.nio.file.Path
import java.nio.file.WatchEvent
import java.util.concurrent.Executors
import better.files.File
import cats.effect.IO
import com.typesafe.scalalogging.StrictLogging
import io.methvin.better.files.RecursiveFileMonitor
import name.orhideous.twicher.Error
import scala.collection.concurrent.TrieMap
import scala.concurrent.ExecutionContext
import scala.util.Random
class QuotesFileRepository(private val quotesDir: File) extends QuotesRepository with StrictLogging {
private final val cache = TrieMap.empty[Int, Quote]
private final val rnd = new Random
private implicit val watcherEC: ExecutionContext =
ExecutionContext.fromExecutor(Executors.newFixedThreadPool(1))
private final val watcher = new RecursiveFileMonitor(quotesDir, logger = logger.underlying) {
override def onEvent(eventType: WatchEvent.Kind[Path], file: File, count: Int): Unit = {
logger.info(s"Detected change in $quotesDir, reloading")
reload()
}
}
watcher.start()
reload()
override def list: IO[Vector[Quote]] = IO.pure(cache.values.toVector)
override def random: IO[Quote] =
if (cache.isEmpty) {
IO.raiseError(Error.NoSuchQuote)
} else {
read(cache.keySet.toVector(rnd.nextInt(cache.size)))
}
override def read(id: Int): IO[Quote] = cache.get(id) match {
case Some(quote) => IO.pure(quote)
case None => IO.raiseError(Error.NoSuchQuote)
}
private def reload(): Unit = {
val quotes = QuotesFileRepository.loadQuotes(quotesDir).map(q => q.id -> q).toMap
cache.clear()
cache ++= quotes
logger.info(s"Loaded ${quotes.size} quotes from $quotesDir")
}
}
object QuotesFileRepository {
private final val pattern = ".*(\\d+)\\.txt$".r
def apply(quotesDir: String): QuotesFileRepository = new QuotesFileRepository(File(quotesDir))
private final implicit val charset: Charset = Charset.forName("UTF-8")
private def loadQuotes(quotesDir: File): Seq[Quote] =
quotesDir
.globRegex(pattern)
.toSeq
.par
.map(parseFile)
.seq
private def parseFile(file: File): Quote = {
val pattern(id) = file.name
val text = file.contentAsString.stripLineEnd.trim
Quote(id.toInt, text)
}
}

View File

@ -0,0 +1,12 @@
package name.orhideous.twicher.persistence
import cats.effect.IO
trait QuotesRepository {
def read(id: Int): IO[Quote]
def list: IO[Vector[Quote]]
def random: IO[Quote]
}