Add Pebble templating feature

Signed-off-by: Till Kottmann <me@deletescape.ch>
This commit is contained in:
Till Kottmann
2019-09-28 21:10:03 +02:00
committed by Sergey Mashkov
parent 785b8091fd
commit 5a237fd37f
5 changed files with 296 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
description = ''
kotlin.sourceSets.jvmMain.dependencies {
api group: 'io.pebbletemplates', name: 'pebble', version: '3.1.0'
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.pebble
import com.mitchellbosecke.pebble.*
import com.mitchellbosecke.pebble.template.*
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.response.*
import io.ktor.util.*
import io.ktor.util.cio.*
import io.ktor.utils.io.*
import java.util.*
/**
* Response content which could be used to respond [ApplicationCalls] like `call.respond(PebbleContent(...))
*
* @param template name of the template to be resolved by Pebble
* @param model which is passed into the template
* @param locale which is used to resolve templates (optional)
* @param etag value for `E-Tag` header (optional)
* @param contentType response's content type which is set to `text/html;charset=utf-8` by default
*/
class PebbleContent(
val template: String,
val model: Map<String, Any>,
val locale: Locale? = null,
val etag: String? = null,
val contentType: ContentType = ContentType.Text.Html.withCharset(Charsets.UTF_8)
)
/**
* Feature for providing Pebble templates as [PebbleContent]
*/
class Pebble(private val engine: PebbleEngine) {
companion object Feature : ApplicationFeature<ApplicationCallPipeline, PebbleEngine.Builder, Pebble> {
override val key = AttributeKey<Pebble>("pebble")
override fun install(pipeline: ApplicationCallPipeline, configure: PebbleEngine.Builder.() -> Unit): Pebble {
val builder = PebbleEngine.Builder().apply {
configure(this)
}
val feature = Pebble(builder.build())
pipeline.sendPipeline.intercept(ApplicationSendPipeline.Transform) { value ->
if (value is PebbleContent) {
val response = feature.process(value)
proceedWith(response)
}
}
return feature
}
}
private fun process(content: PebbleContent): PebbleOutgoingContent {
return PebbleOutgoingContent(
engine.getTemplate(content.template),
content.model,
content.locale,
content.etag,
content.contentType
)
}
/**
* Content which is responded when Pebble templates are rendered.
*
* @param template the compiled [com.mitchellbosecke.pebble.template.PebbleTemplate] template
* @param model the model provided into the template
* @param locale which is used to resolve templates (optional)
* @param etag value for `E-Tag` header (optional)
* @param contentType response's content type which is set to `text/html;charset=utf-8` by default
*/
private class PebbleOutgoingContent(
val template: PebbleTemplate,
val model: Map<String, Any>,
val locale: Locale?,
etag: String?,
override val contentType: ContentType
) : OutgoingContent.WriteChannelContent() {
override suspend fun writeTo(channel: ByteWriteChannel) {
channel.bufferedWriter(contentType.charset() ?: Charsets.UTF_8).use {
template.evaluate(it, model, locale)
}
}
init {
if (etag != null)
versions += EntityTagVersion(etag)
}
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.pebble
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import java.util.*
/**
* Respond with the specified [template] passing [model]
*
* @see PebbleContent
*/
suspend fun ApplicationCall.respondTemplate(
template: String,
model: Map<String, Any>,
locale: Locale? = null,
etag: String? = null,
contentType: ContentType = ContentType.Text.Html.withCharset(
Charsets.UTF_8
)
) = respond(PebbleContent(template, model, locale, etag, contentType))

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
package io.ktor.pebble
import com.mitchellbosecke.pebble.loader.*
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.testing.*
import java.util.zip.*
import kotlin.test.*
class PebbleTest {
@Test
fun `Fill template and expect correct rendered content`() {
withTestApplication {
application.setupPebble()
application.install(ConditionalHeaders)
application.routing {
get("/") {
call.respond(PebbleContent(TemplateWithPlaceholder, DefaultModel, etag = "e"))
}
}
handleRequest(HttpMethod.Get, "/").response.let { response ->
val lines = response.content!!.lines()
assertEquals("<p>Hello, 1</p>", lines[0])
assertEquals("<h1>Hello World!</h1>", lines[1])
}
}
}
@Test
fun `Fill template and expect correct default content type`() {
withTestApplication {
application.setupPebble()
application.install(ConditionalHeaders)
application.routing {
get("/") {
call.respond(PebbleContent(TemplateWithPlaceholder, DefaultModel, etag = "e"))
}
}
handleRequest(HttpMethod.Get, "/").response.let { response ->
val contentTypeText = assertNotNull(response.headers[HttpHeaders.ContentType])
assertEquals(ContentType.Text.Html.withCharset(Charsets.UTF_8), ContentType.parse(contentTypeText))
}
}
}
@Test
fun `Fill template and expect eTag set when it is provided`() {
withTestApplication {
application.setupPebble()
application.install(ConditionalHeaders)
application.routing {
get("/") {
call.respond(PebbleContent(TemplateWithPlaceholder, DefaultModel, etag = "e"))
}
}
assertEquals("e", handleRequest(HttpMethod.Get, "/").response.headers[HttpHeaders.ETag])
}
}
@Test
fun `Render empty model`() {
withTestApplication {
application.setupPebble()
application.install(ConditionalHeaders)
application.routing {
get("/") {
call.respond(PebbleContent(TemplateWithoutPlaceholder, emptyMap(), etag = "e"))
}
}
handleRequest(HttpMethod.Get, "/").response.let { response ->
val lines = response.content!!.lines()
assertEquals("<p>Hello, Anonymous</p>", lines[0])
assertEquals("<h1>Hi!</h1>", lines[1])
}
}
}
@Test
fun `Render template compressed with GZIP`() {
withTestApplication {
application.setupPebble()
application.install(Compression)
application.install(ConditionalHeaders)
application.routing {
get("/") {
call.respondTemplate(TemplateWithPlaceholder, DefaultModel, etag = "e")
}
}
handleRequest(HttpMethod.Get, "/") {
addHeader(HttpHeaders.AcceptEncoding, "gzip")
}.response.let { response ->
val content = GZIPInputStream(response.byteContent!!.inputStream()).reader().readText()
val lines = content.lines()
assertEquals("<p>Hello, 1</p>", lines[0])
assertEquals("<h1>Hello World!</h1>", lines[1])
}
}
}
@Test
fun `Render template without eTag`() {
withTestApplication {
application.setupPebble()
application.install(ConditionalHeaders)
application.routing {
get("/") {
call.respond(PebbleContent(TemplateWithPlaceholder, DefaultModel))
}
}
assertEquals(null, handleRequest(HttpMethod.Get, "/").response.headers[HttpHeaders.ETag])
}
}
private fun Application.setupPebble() {
install(Pebble) {
loader(StringLoader())
}
}
companion object {
private val DefaultModel = mapOf("id" to 1, "title" to "Hello World!")
private val TemplateWithPlaceholder
get() = """
<p>Hello, {{id}}</p>
<h1>{{title}}</h1>
""".trimIndent()
private val TemplateWithoutPlaceholder: String
get() = """
<p>Hello, Anonymous</p>
<h1>Hi!</h1>
""".trimIndent()
}
}

View File

@@ -57,6 +57,7 @@ include ':ktor-features:ktor-freemarker'
include ':ktor-features:ktor-mustache'
include ':ktor-features:ktor-thymeleaf'
include ':ktor-features:ktor-velocity'
include ':ktor-features:ktor-pebble'
include ':ktor-features:ktor-gson'
include ':ktor-features:ktor-jackson'
include ':ktor-features:ktor-metrics'