mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 02:18:26 +00:00
Initial application stub
This commit is contained in:
14
modules/restserver/src/main/resources/logback.xml
Normal file
14
modules/restserver/src/main/resources/logback.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<withJansi>true</withJansi>
|
||||
|
||||
<encoder>
|
||||
<pattern>[%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="docspell" level="debug" />
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</configuration>
|
3
modules/restserver/src/main/resources/reference.conf
Normal file
3
modules/restserver/src/main/resources/reference.conf
Normal file
@ -0,0 +1,3 @@
|
||||
docspell.restserver {
|
||||
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package docspell.restserver
|
||||
|
||||
import docspell.store.JdbcConfig
|
||||
|
||||
case class Config(appName: String
|
||||
, baseUrl: String
|
||||
, bind: Config.Bind
|
||||
, jdbc: JdbcConfig
|
||||
)
|
||||
|
||||
object Config {
|
||||
|
||||
|
||||
val default: Config =
|
||||
Config("Docspell", "http://localhost:7880", Config.Bind("localhost", 7880), JdbcConfig("", "", ""))
|
||||
|
||||
|
||||
case class Bind(address: String, port: Int)
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
package docspell.restserver
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s._
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.http4s.circe.CirceEntityEncoder._
|
||||
|
||||
import docspell.restapi.model._
|
||||
import docspell.restserver.BuildInfo
|
||||
|
||||
object InfoRoutes {
|
||||
|
||||
def apply[F[_]: Sync](cfg: Config): HttpRoutes[F] = {
|
||||
val dsl = new Http4sDsl[F]{}
|
||||
import dsl._
|
||||
HttpRoutes.of[F] {
|
||||
case GET -> (Root / "version") =>
|
||||
Ok(VersionInfo(BuildInfo.version
|
||||
, BuildInfo.builtAtMillis
|
||||
, BuildInfo.builtAtString
|
||||
, BuildInfo.gitHeadCommit.getOrElse("")
|
||||
, BuildInfo.gitDescribedVersion.getOrElse("")))
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package docspell.restserver
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import scala.concurrent.ExecutionContext
|
||||
import java.util.concurrent.Executors
|
||||
import java.nio.file.{Files, Paths}
|
||||
import org.log4s._
|
||||
|
||||
object Main extends IOApp {
|
||||
private[this] val logger = getLogger
|
||||
|
||||
val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(Executors.newCachedThreadPool)
|
||||
val blocker = Blocker.liftExecutionContext(blockingEc)
|
||||
|
||||
def run(args: List[String]) = {
|
||||
args match {
|
||||
case file :: Nil =>
|
||||
val path = Paths.get(file).toAbsolutePath.normalize
|
||||
logger.info(s"Using given config file: $path")
|
||||
System.setProperty("config.file", file)
|
||||
case _ =>
|
||||
Option(System.getProperty("config.file")) match {
|
||||
case Some(f) if f.nonEmpty =>
|
||||
val path = Paths.get(f).toAbsolutePath.normalize
|
||||
if (!Files.exists(path)) {
|
||||
logger.info(s"Not using config file '$f' because it doesn't exist")
|
||||
System.clearProperty("config.file")
|
||||
} else {
|
||||
logger.info(s"Using config file from system properties: $f")
|
||||
}
|
||||
case _ =>
|
||||
}
|
||||
}
|
||||
|
||||
val cfg = Config.default
|
||||
RestServer.stream[IO](cfg, blocker).compile.drain.as(ExitCode.Success)
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package docspell.restserver
|
||||
|
||||
trait RestApp[F[_]] {
|
||||
|
||||
def init: F[Unit]
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package docspell.restserver
|
||||
|
||||
import cats.effect._
|
||||
|
||||
final class RestAppImpl[F[_]: Sync](cfg: Config) extends RestApp[F] {
|
||||
|
||||
def init: F[Unit] =
|
||||
Sync[F].pure(())
|
||||
|
||||
}
|
||||
|
||||
object RestAppImpl {
|
||||
|
||||
def create[F[_]: Sync](cfg: Config): Resource[F, RestApp[F]] =
|
||||
Resource.liftF(Sync[F].pure(new RestAppImpl(cfg)))
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package docspell.restserver
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s.server.blaze.BlazeServerBuilder
|
||||
import org.http4s.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import org.http4s.server.middleware.Logger
|
||||
import org.http4s.server.Router
|
||||
|
||||
import docspell.restserver.webapp._
|
||||
|
||||
object RestServer {
|
||||
|
||||
def stream[F[_]: ConcurrentEffect](cfg: Config, blocker: Blocker)
|
||||
(implicit T: Timer[F], CS: ContextShift[F]): Stream[F, Nothing] = {
|
||||
|
||||
val app = for {
|
||||
restApp <- RestAppImpl.create[F](cfg)
|
||||
_ <- Resource.liftF(restApp.init)
|
||||
|
||||
httpApp = Router(
|
||||
"/api/info" -> InfoRoutes(cfg),
|
||||
"/app/assets" -> WebjarRoutes.appRoutes[F](blocker, cfg),
|
||||
"/app" -> TemplateRoutes[F](blocker, cfg)
|
||||
).orNotFound
|
||||
|
||||
// With Middlewares in place
|
||||
finalHttpApp = Logger.httpApp(false, false)(httpApp)
|
||||
|
||||
} yield finalHttpApp
|
||||
|
||||
|
||||
Stream.resource(app).flatMap(httpApp =>
|
||||
BlazeServerBuilder[F]
|
||||
.bindHttp(cfg.bind.port, cfg.bind.address)
|
||||
.withHttpApp(httpApp)
|
||||
.serve
|
||||
)
|
||||
|
||||
}.drain
|
||||
}
|
@ -0,0 +1,139 @@
|
||||
package docspell.restserver.webapp
|
||||
|
||||
import fs2._
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import org.http4s._
|
||||
import org.http4s.headers._
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
import org.slf4j._
|
||||
import _root_.io.circe._
|
||||
import _root_.io.circe.generic.semiauto._
|
||||
import _root_.io.circe.syntax._
|
||||
import yamusca.imports._
|
||||
import yamusca.implicits._
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
import docspell.restserver.{BuildInfo, Config}
|
||||
|
||||
object TemplateRoutes {
|
||||
private[this] val logger = LoggerFactory.getLogger(getClass)
|
||||
|
||||
val `text/html` = new MediaType("text", "html")
|
||||
|
||||
def apply[F[_]: Effect](blocker: Blocker, cfg: Config)(implicit C: ContextShift[F]): HttpRoutes[F] = {
|
||||
val indexTemplate = memo(loadResource("/index.html").flatMap(loadTemplate(_, blocker)))
|
||||
val docTemplate = memo(loadResource("/doc.html").flatMap(loadTemplate(_, blocker)))
|
||||
|
||||
val dsl = new Http4sDsl[F]{}
|
||||
import dsl._
|
||||
HttpRoutes.of[F] {
|
||||
case GET -> Root / "index.html" =>
|
||||
for {
|
||||
templ <- indexTemplate
|
||||
resp <- Ok(IndexData(cfg).render(templ), `Content-Type`(`text/html`))
|
||||
} yield resp
|
||||
case GET -> Root / "doc" =>
|
||||
for {
|
||||
templ <- docTemplate
|
||||
resp <- Ok(DocData(cfg).render(templ), `Content-Type`(`text/html`))
|
||||
} yield resp
|
||||
}
|
||||
}
|
||||
|
||||
def loadResource[F[_]: Sync](name: String): F[URL] = {
|
||||
Option(getClass.getResource(name)) match {
|
||||
case None =>
|
||||
Sync[F].raiseError(new Exception("Unknown resource: "+ name))
|
||||
case Some(r) =>
|
||||
r.pure[F]
|
||||
}
|
||||
}
|
||||
|
||||
def loadUrl[F[_]: Sync](url: URL, blocker: Blocker)(implicit C: ContextShift[F]): F[String] =
|
||||
Stream.bracket(Sync[F].delay(url.openStream))(in => Sync[F].delay(in.close)).
|
||||
flatMap(in => io.readInputStream(in.pure[F], 64 * 1024, blocker, false)).
|
||||
through(text.utf8Decode).
|
||||
compile.fold("")(_ + _)
|
||||
|
||||
def parseTemplate[F[_]: Sync](str: String): F[Template] =
|
||||
Sync[F].delay {
|
||||
mustache.parse(str) match {
|
||||
case Right(t) => t
|
||||
case Left((_, err)) => sys.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
def loadTemplate[F[_]: Sync](url: URL, blocker: Blocker)(implicit C: ContextShift[F]): F[Template] = {
|
||||
loadUrl[F](url, blocker).flatMap(s => parseTemplate(s)).
|
||||
map(t => {
|
||||
logger.info(s"Compiled template $url")
|
||||
t
|
||||
})
|
||||
}
|
||||
|
||||
case class DocData(swaggerRoot: String, openapiSpec: String)
|
||||
object DocData {
|
||||
|
||||
def apply(cfg: Config): DocData =
|
||||
DocData("/app/assets" + Webjars.swaggerui, s"/app/assets/${BuildInfo.name}/${BuildInfo.version}/openapi.yml")
|
||||
|
||||
implicit def yamuscaValueConverter: ValueConverter[DocData] =
|
||||
ValueConverter.deriveConverter[DocData]
|
||||
}
|
||||
|
||||
case class Flags(appName: String, baseUrl: String)
|
||||
|
||||
object Flags {
|
||||
def apply(cfg: Config): Flags =
|
||||
Flags(cfg.appName, cfg.baseUrl)
|
||||
|
||||
implicit val jsonEncoder: Encoder[Flags] =
|
||||
deriveEncoder[Flags]
|
||||
implicit def yamuscaValueConverter: ValueConverter[Flags] =
|
||||
ValueConverter.deriveConverter[Flags]
|
||||
}
|
||||
|
||||
case class IndexData(flags: Flags
|
||||
, cssUrls: Seq[String]
|
||||
, jsUrls: Seq[String]
|
||||
, appExtraJs: String
|
||||
, flagsJson: String)
|
||||
|
||||
object IndexData {
|
||||
|
||||
def apply(cfg: Config): IndexData =
|
||||
IndexData(Flags(cfg)
|
||||
, Seq(
|
||||
"/app/assets" + Webjars.semanticui + "/semantic.min.css",
|
||||
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.css"
|
||||
)
|
||||
, Seq(
|
||||
"/app/assets" + Webjars.jquery + "/jquery.min.js",
|
||||
"/app/assets" + Webjars.semanticui + "/semantic.min.js",
|
||||
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js"
|
||||
)
|
||||
,
|
||||
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.js"
|
||||
, Flags(cfg).asJson.spaces2 )
|
||||
|
||||
implicit def yamuscaValueConverter: ValueConverter[IndexData] =
|
||||
ValueConverter.deriveConverter[IndexData]
|
||||
}
|
||||
|
||||
private def memo[F[_]: Sync, A](fa: => F[A]): F[A] = {
|
||||
val ref = new AtomicReference[A]()
|
||||
Sync[F].suspend {
|
||||
Option(ref.get) match {
|
||||
case Some(a) => a.pure[F]
|
||||
case None =>
|
||||
fa.map(a => {
|
||||
ref.set(a)
|
||||
a
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package docspell.restserver.webapp
|
||||
|
||||
import cats.effect._
|
||||
import org.http4s._
|
||||
import org.http4s.HttpRoutes
|
||||
import org.http4s.server.staticcontent.webjarService
|
||||
import org.http4s.server.staticcontent.NoopCacheStrategy
|
||||
import org.http4s.server.staticcontent.WebjarService.{WebjarAsset, Config => WebjarConfig}
|
||||
|
||||
import docspell.restserver.Config
|
||||
|
||||
object WebjarRoutes {
|
||||
|
||||
def appRoutes[F[_]: Effect](blocker: Blocker, cfg: Config)(implicit C: ContextShift[F]): HttpRoutes[F] = {
|
||||
webjarService(
|
||||
WebjarConfig(
|
||||
filter = assetFilter,
|
||||
blocker = blocker,
|
||||
cacheStrategy = NoopCacheStrategy[F]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def assetFilter(asset: WebjarAsset): Boolean =
|
||||
List(".js", ".css", ".html", ".jpg", ".png", ".eot", ".woff", ".woff2", ".svg", ".otf", ".ttf", ".yml").
|
||||
exists(e => asset.asset.endsWith(e))
|
||||
|
||||
}
|
60
modules/restserver/src/main/templates/doc.html
Normal file
60
modules/restserver/src/main/templates/doc.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Swagger UI</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{swaggerRoot}}/swagger-ui.css" >
|
||||
<link rel="icon" type="image/png" href="{{swaggerRoot}}/favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="{{swaggerRoot}}/favicon-16x16.png" sizes="16x16" />
|
||||
<style>
|
||||
html
|
||||
{
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after
|
||||
{
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
margin:0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="{{swaggerRoot}}/swagger-ui-bundle.js"> </script>
|
||||
<script src="{{swaggerRoot}}/swagger-ui-standalone-preset.js"> </script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "{{openapiSpec}}",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
displayRequestDuration: true
|
||||
})
|
||||
// End Swagger UI call region
|
||||
|
||||
window.ui = ui
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
31
modules/restserver/src/main/templates/index.html
Normal file
31
modules/restserver/src/main/templates/index.html
Normal file
@ -0,0 +1,31 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
<title>{{ flags.appName }}</title>
|
||||
{{# cssUrls }}
|
||||
<link rel="stylesheet" href="{{.}}"/>
|
||||
{{/ cssUrls }}
|
||||
{{# jsUrls }}
|
||||
<script type="application/javascript" src="{{.}}"></script>
|
||||
{{/ jsUrls}}
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="docspell-app">
|
||||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
var storedAccount = localStorage.getItem('account');
|
||||
var account = storedAccount ? JSON.parse(storedAccount) : null;
|
||||
var elmFlags = {
|
||||
"account": account,
|
||||
"config": {{{flagsJson}}}
|
||||
};
|
||||
</script>
|
||||
<script type="application/javascript" src="{{appExtraJs}}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
Reference in New Issue
Block a user