Refactor + // img download

This commit is contained in:
Hubert Van De Walle 2020-09-27 17:49:56 +02:00
parent b1e2efa75f
commit d3bcff470c
19 changed files with 280 additions and 247 deletions

16
pom.xml
View File

@ -9,7 +9,7 @@
<maven.compiler.target>${java.version}</maven.compiler.target>
<maven.compiler.source>${java.version}</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.class>be.simplenotes.c2c/C2cKt</main.class>
<main.class>be.simplenotes.c2c/Camp2CampKt</main.class>
<openhtml.version>1.0.4</openhtml.version>
</properties>
<dependencies>
@ -204,20 +204,6 @@
<mainClass>${main.class}</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>com.github.ben-manes.caffeine:caffeine</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.jetbrains.kotlin:kotlin-reflect</artifact>
<includes>
<include>**</include>
</includes>
</filter>
</filters>
</configuration>
</execution>
</executions>

View File

@ -1,102 +0,0 @@
package be.simplenotes.c2c
import be.simplenotes.c2c.api.Api
import be.simplenotes.c2c.api.ApiUrls
import be.simplenotes.c2c.pdf.PdfCreator
import be.simplenotes.c2c.pdf.StreamFactory
import be.simplenotes.c2c.routes.IndexRoute
import be.simplenotes.c2c.routes.PdfRoute
import be.simplenotes.c2c.routes.RouteRoute
import be.simplenotes.c2c.routes.SearchRoute
import com.mitchellbosecke.pebble.PebbleEngine
import com.mitchellbosecke.pebble.loader.ClasspathLoader
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder
import org.http4k.client.ApacheClient
import org.http4k.contract.ContractRoute
import org.http4k.contract.contract
import org.http4k.contract.openapi.ApiInfo
import org.http4k.contract.openapi.v3.OpenApi3
import org.http4k.core.Filter
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.then
import org.http4k.filter.RequestFilters
import org.http4k.filter.ServerFilters
import org.http4k.format.Jackson
import org.http4k.server.asServer
import org.koin.core.context.startKoin
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
import org.slf4j.LoggerFactory
import java.io.PrintWriter
import java.io.StringWriter
val module = module {
single {
ApacheClient(
HttpClientBuilder
.create()
.setRedirectStrategy(DefaultRedirectStrategy())
.build()
)
}
single { ApiUrls(get()) }
single { Jackson.mapper }
single { Api(get(), get()) }
single { PdfCreator(get()) }
single { StreamFactory(get()) }
single {
PebbleEngine
.Builder()
.loader(ClasspathLoader().apply {
prefix = "views/"
suffix = ".twig"
})
.cacheActive(false)
.build()
}
}
val routes = module {
single(named<SearchRoute>()) { SearchRoute(get()).searchRoute() } bind ContractRoute::class
single(named<RouteRoute>()) { RouteRoute(get()).routeRoute() } bind ContractRoute::class
single(named<PdfRoute>()) { PdfRoute(get(), get()).route() } bind ContractRoute::class
single(named<IndexRoute>()) { IndexRoute(get()).route() } bind ContractRoute::class
}
fun main() {
val koin = startKoin {
modules(module, routes)
}.koin
val appRoutes = koin.getAll<ContractRoute>()
val app = contract {
renderer = OpenApi3(ApiInfo("Camp2Camp", "1.0-SNAPSHOT"), Jackson)
descriptionPath = "/api/swagger.json"
routes.all.addAll(appRoutes)
}
val logger = LoggerFactory.getLogger("Camp2Camp")
val loggingFilter = RequestFilters.Tap {
logger.info("${it.method} ${it.uri}")
}
val catchAll = Filter { next ->
{
try {
next(it)
} catch (e: Exception) {
val sw = StringWriter()
e.printStackTrace(PrintWriter(sw))
logger.error(sw.toString())
Response(Status.INTERNAL_SERVER_ERROR)
}
}
}
catchAll.then(ServerFilters.GZip()).then(loggingFilter).then(app).asServer(CustomApacheServer(4000)).start()
logger.info("Listening on http://localhost:4000")
}

View File

@ -0,0 +1,53 @@
package be.simplenotes.c2c
import be.simplenotes.c2c.filters.catchAllFilter
import be.simplenotes.c2c.filters.loggingFilter
import be.simplenotes.c2c.routes.RouteProvider
import org.http4k.contract.contract
import org.http4k.contract.openapi.ApiInfo
import org.http4k.contract.openapi.v3.OpenApi3
import org.http4k.core.then
import org.http4k.filter.ServerFilters
import org.http4k.format.Jackson
import org.http4k.server.ServerConfig
import org.http4k.server.asServer
import org.koin.core.context.startKoin
import org.slf4j.LoggerFactory
import java.net.InetSocketAddress
fun main() {
val koin = startKoin {
modules(
serverModule,
routes,
clientModule,
apiModule,
utilsModule,
pdfModule,
)
properties(mapOf(
"port" to System.getenv().getOrDefault("PORT", "4000"),
"host" to System.getenv().getOrDefault("HOST", "localhost"),
))
}.koin
val appRoutes = koin.getAll<RouteProvider>().map(RouteProvider::route)
val app = contract {
renderer = OpenApi3(ApiInfo("Camp2Camp", "1.0-SNAPSHOT"), Jackson)
descriptionPath = "/api/swagger.json"
routes.all.addAll(appRoutes)
}
val logger = LoggerFactory.getLogger("Camp2Camp")
val server = koin.get<ServerConfig>()
val address = koin.get<InetSocketAddress>()
catchAllFilter(logger)
.then(ServerFilters.GZip())
.then(loggingFilter(logger))
.then(app)
.asServer(server)
.start()
logger.info("Listening on http://${address.hostName}:${address.port}")
}

View File

@ -0,0 +1,53 @@
package be.simplenotes.c2c
import be.simplenotes.c2c.api.Api
import be.simplenotes.c2c.api.ApiUrls
import be.simplenotes.c2c.api.ImageDownloader
import be.simplenotes.c2c.pdf.PdfCreator
import be.simplenotes.c2c.routes.*
import be.simplenotes.c2c.server.CustomApacheServer
import be.simplenotes.c2c.templates.PebbleEngine
import com.mitchellbosecke.pebble.PebbleEngine
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder
import org.http4k.client.ApacheClient
import org.http4k.format.Jackson
import org.http4k.server.ServerConfig
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
import java.net.InetSocketAddress
val routes = module {
single { SearchRouteProvider(get()) } bind RouteProvider::class
single { RouteRouteProvider(get()) } bind RouteProvider::class
single { PdfRouteProvider(get(), get(), get()) } bind RouteProvider::class
single { IndexRouteProvider(get()) } bind RouteProvider::class
}
val serverModule = module {
single { InetSocketAddress(getProperty("host"), getProperty("port").toInt()) }
single<ServerConfig> { CustomApacheServer(get()) }
}
val clientModule = module {
single { ApacheClient(get()) }
single { HttpClientBuilder.create().setRedirectStrategy(DefaultRedirectStrategy()).build() }
}
val apiModule = module {
single { ApiUrls(get()) }
single { Api(get(), get()) }
single { ImageDownloader(get()) }
}
val utilsModule = module {
single { Jackson.mapper }
single { PebbleEngine(cache = false, prefix = "templates/") }
}
val pdfModule = module {
val pdf = named("pdf")
single { PdfCreator(get(pdf)) }
single(pdf) { get<PebbleEngine>().getTemplate("pdf") }
}

View File

@ -0,0 +1,24 @@
package be.simplenotes.c2c.api
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Request
import org.slf4j.LoggerFactory
import java.util.stream.Collectors
class ImageDownloader(private val client: HttpHandler) {
private val logger = LoggerFactory.getLogger(javaClass)
fun download(images: List<Image>): Map<String, ByteArray?> =
images.map(Image::medium)
.parallelStream()
.collect(Collectors.toConcurrentMap(
{ it },
{
val res = client(Request(Method.GET, it))
logger.info("GET ${res.status} $it")
if (res.status.successful) res.body.stream.readAllBytes()
else null
}
))
}

View File

@ -0,0 +1,26 @@
package be.simplenotes.c2c.filters
import org.http4k.core.Filter
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.filter.RequestFilters
import org.slf4j.Logger
import java.io.PrintWriter
import java.io.StringWriter
fun loggingFilter(logger: Logger) = RequestFilters.Tap {
logger.info("${it.method} ${it.uri}")
}
fun catchAllFilter(logger: Logger) = Filter { next ->
{
try {
next(it)
} catch (e: Exception) {
val sw = StringWriter()
e.printStackTrace(PrintWriter(sw))
logger.error(sw.toString())
Response(Status.INTERNAL_SERVER_ERROR)
}
}
}

View File

@ -2,59 +2,19 @@ package be.simplenotes.c2c.pdf
import be.simplenotes.c2c.api.Route
import be.simplenotes.c2c.templates.render
import com.github.benmanes.caffeine.cache.Caffeine
import com.mitchellbosecke.pebble.PebbleEngine
import com.mitchellbosecke.pebble.loader.ClasspathLoader
import com.openhtmltopdf.extend.FSStream
import com.mitchellbosecke.pebble.template.PebbleTemplate
import com.openhtmltopdf.extend.FSStreamFactory
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder
import com.openhtmltopdf.util.XRLog
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Request
import org.jsoup.Jsoup
import org.jsoup.helper.W3CDom
import org.slf4j.LoggerFactory
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.util.concurrent.TimeUnit
class StreamFactory(private val client: HttpHandler) : FSStreamFactory {
private val logger = LoggerFactory.getLogger(javaClass)
class PdfCreator(private val template: PebbleTemplate) {
private val imgCache = Caffeine.newBuilder()
.maximumSize(15)
.expireAfterWrite(15, TimeUnit.MINUTES)
.build<String, ByteArray>()
override fun getUrl(url: String) = object : FSStream {
override fun getStream(): InputStream? {
val bytes = imgCache.get(url) {
logger.info("Downloading $url")
val res = client(Request(Method.GET, url))
if (res.status.successful) res.body.stream.readAllBytes()
else null
}
return bytes?.inputStream()
}
override fun getReader() = null
}
}
class PdfCreator(private val streamFactory: StreamFactory) {
private val engine = PebbleEngine
.Builder()
.loader(ClasspathLoader().apply {
prefix = "templates/"
suffix = ".twig"
})
.cacheActive(true)
.build()
fun create(route: Route): ByteArrayInputStream {
val html = engine.render("index", mapOf("route" to route))
fun create(route: Route, streamFactory: FSStreamFactory): ByteArrayInputStream {
val html = template.render(mapOf("route" to route))
XRLog.setLoggingEnabled(false)

View File

@ -0,0 +1,12 @@
package be.simplenotes.c2c.pdf
import com.openhtmltopdf.extend.FSStream
import com.openhtmltopdf.extend.FSStreamFactory
import java.io.InputStream
class PreloadedStreamFactory(private val images: Map<String, ByteArray?>) : FSStreamFactory {
override fun getUrl(url: String) = object : FSStream {
override fun getStream(): InputStream? = images[url]?.inputStream()
override fun getReader() = null
}
}

View File

@ -4,20 +4,18 @@ import be.simplenotes.c2c.templates.render
import com.mitchellbosecke.pebble.PebbleEngine
import org.http4k.contract.ContractRoute
import org.http4k.contract.meta
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.*
class IndexRoute(private val engine: PebbleEngine) {
class IndexRouteProvider(private val engine: PebbleEngine) : RouteProvider {
fun route(): ContractRoute {
override fun route(): ContractRoute {
val spec = "/" meta {
summary = "Redoc ui"
produces += ContentType.TEXT_HTML
} bindContract Method.GET
val route: HttpHandler = {
Response(Status.OK).body(engine.render("index"))
Response(Status.OK).body(engine.render("index")).header("Content-Type", "text/html; charset=utf-8")
}
return spec to route

View File

@ -1,38 +0,0 @@
package be.simplenotes.c2c.routes
import be.simplenotes.c2c.api.Api
import be.simplenotes.c2c.pdf.PdfCreator
import org.http4k.contract.ContractRoute
import org.http4k.contract.div
import org.http4k.contract.meta
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.lens.Path
// TODO: rename..
class PdfRoute(private val api: Api, private val pdfCreator: PdfCreator) {
fun route(): ContractRoute {
val spec = "/route" / Path.of("id", "Route's ID") / "pdf" meta {
summary = "Generate pdf from routes"
} bindContract Method.GET
fun route(id: String, pdf: String): HttpHandler = {
when (val route = api.getRoute(id)) {
null -> Response(Status.NOT_FOUND)
else -> {
val bytes = pdfCreator.create(route)
Response(Status.OK)
.body(bytes)
.header("Content-Type", "application/pdf")
.header("Content-Disposition", "attachment; filename=\"out.pdf\"")
}
}
}
return spec to ::route
}
}

View File

@ -0,0 +1,39 @@
package be.simplenotes.c2c.routes
import be.simplenotes.c2c.api.Api
import be.simplenotes.c2c.api.ImageDownloader
import be.simplenotes.c2c.pdf.PdfCreator
import be.simplenotes.c2c.pdf.PreloadedStreamFactory
import org.http4k.contract.ContractRoute
import org.http4k.contract.div
import org.http4k.contract.meta
import org.http4k.core.*
import org.http4k.lens.Path
class PdfRouteProvider(
private val api: Api,
private val pdfCreator: PdfCreator,
private val imageDownloader: ImageDownloader,
) : RouteProvider {
override fun route(): ContractRoute {
val spec = "/route" / Path.of("id", "Route's ID") / "pdf" meta {
summary = "Generate pdf from routes"
produces += ContentType("application/pdf")
} bindContract Method.GET
fun route(id: String, pdf: String): HttpHandler = {
api.getRoute(id)?.let {
val imageMap = imageDownloader.download(it.associations.images)
Response(Status.OK)
.body(pdfCreator.create(it, PreloadedStreamFactory(imageMap)))
.header("Content-Type", "application/pdf")
.header("Content-Disposition", "attachment; filename=\"out.pdf\"")
} ?: Response(Status.NOT_FOUND)
}
return spec to ::route
}
}

View File

@ -0,0 +1,7 @@
package be.simplenotes.c2c.routes
import org.http4k.contract.ContractRoute
interface RouteProvider {
fun route(): ContractRoute
}

View File

@ -10,13 +10,14 @@ import org.http4k.format.Jackson.auto
import org.http4k.lens.Path
// TODO: rename..
class RouteRoute(private val api: Api) {
class RouteRouteProvider(private val api: Api) : RouteProvider {
fun routeRoute(): ContractRoute {
override fun route(): ContractRoute {
val responseLens = Body.auto<Route>("Route").toLens()
val spec = "/route" / Path.of("id", "Route's ID") meta {
summary = "Route details"
produces += ContentType.APPLICATION_JSON
} bindContract Method.GET
fun route(id: String): HttpHandler = {

View File

@ -5,21 +5,22 @@ import be.simplenotes.c2c.api.Api
import be.simplenotes.c2c.api.RouteResult
import be.simplenotes.c2c.api.SearchResults
import org.http4k.contract.ContractRoute
import org.http4k.contract.Tag
import org.http4k.contract.RequestMeta
import org.http4k.contract.meta
import org.http4k.core.*
import org.http4k.format.Jackson.auto
import org.http4k.lens.Query
class SearchRoute(private val api: Api) {
class SearchRouteProvider(private val api: Api) : RouteProvider {
fun searchRoute(): ContractRoute {
override fun route(): ContractRoute {
val query = Query.required("q", "Search terms")
val responseLens = Body.auto<SearchResults>("Search results").toLens()
val spec = "/search" meta {
summary = "Search routes and waypoints"
queries += query
receiving(RequestMeta(Request(Method.GET, "/search").query("q", "tour de bavon")))
returning(Status.OK, responseLens to SearchResults(listOf(RouteResult(
id = "57103",
title = "Tour de Bavon : Thor",

View File

@ -1,4 +1,4 @@
package be.simplenotes.c2c
package be.simplenotes.c2c.server
import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap
import org.apache.hc.core5.http.io.SocketConfig
@ -6,13 +6,16 @@ import org.http4k.core.HttpHandler
import org.http4k.server.Http4kRequestHandler
import org.http4k.server.Http4kServer
import org.http4k.server.ServerConfig
import java.net.InetSocketAddress
class CustomApacheServer(private val socketAddress: InetSocketAddress) : ServerConfig {
class CustomApacheServer(val port: Int) : ServerConfig {
override fun toServer(httpHandler: HttpHandler): Http4kServer = object : Http4kServer {
val handler = Http4kRequestHandler(httpHandler)
val server = ServerBootstrap.bootstrap()
.setListenerPort(port)
.setListenerPort(socketAddress.port)
.setLocalAddress(socketAddress.address)
.setSocketConfig(SocketConfig.custom()
.setTcpNoDelay(true)
.setSoKeepAlive(true)
@ -28,6 +31,6 @@ class CustomApacheServer(val port: Int) : ServerConfig {
override fun stop() = apply { server.stop() }
override fun port(): Int = port
override fun port(): Int = socketAddress.port
}
}

View File

@ -1,11 +1,21 @@
package be.simplenotes.c2c.templates
import com.mitchellbosecke.pebble.PebbleEngine
import com.mitchellbosecke.pebble.loader.ClasspathLoader
import com.mitchellbosecke.pebble.template.PebbleTemplate
import java.io.StringWriter
fun PebbleEngine.render(name: String, args: Map<String, Any?> = mapOf()): String {
val template = getTemplate(name)
fun PebbleEngine.render(name: String, args: Map<String, Any?> = mapOf()) = getTemplate(name).render(args)
fun PebbleTemplate.render(args: Map<String, Any?> = mapOf()): String {
val writer = StringWriter()
template.evaluate(writer, args)
evaluate(writer, args)
return writer.toString()
}
@Suppress("FunctionName")
fun PebbleEngine(cache: Boolean, prefix: String? = null, suffix: String? = ".twig"): PebbleEngine =
PebbleEngine.Builder().loader(ClasspathLoader().also { loader ->
prefix?.let { loader.prefix = it }
suffix?.let { loader.suffix = it }
}).cacheActive(cache).build()

View File

@ -1,10 +1,19 @@
<h1>{{ route.title }}</h1>
<p>{{ route.summary }}</p>
<p>{{ route.description }}</p>
{% for img in route.associations.images %}
<figure>
<img src="{{ img.medium }}"
alt="{{ img.title }}">
<figcaption>{{ img.title }}</figcaption>
</figure>
{% endfor %}
<!DOCTYPE html>
<html lang="en">
<head>
<title>ReDoc</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='/api/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
<h1>{{ route.title }}</h1>
<p>{{ route.summary }}</p>
<p>{{ route.description }}</p>
{% for img in route.associations.images %}
<figure>
<img src="{{ img.medium }}"
alt="{{ img.title }}">
<figcaption>{{ img.title }}</figcaption>
</figure>
{% endfor %}

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>ReDoc</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='/api/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"></script>
</body>
</html>