diff --git a/ktor-features/ktor-pebble/build.gradle b/ktor-features/ktor-pebble/build.gradle new file mode 100644 index 000000000..1c87a508d --- /dev/null +++ b/ktor-features/ktor-pebble/build.gradle @@ -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' +} diff --git a/ktor-features/ktor-pebble/jvm/src/io/ktor/pebble/Pebble.kt b/ktor-features/ktor-pebble/jvm/src/io/ktor/pebble/Pebble.kt new file mode 100644 index 000000000..d55339fca --- /dev/null +++ b/ktor-features/ktor-pebble/jvm/src/io/ktor/pebble/Pebble.kt @@ -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, + 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 { + override val key = AttributeKey("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, + 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) + } + } +} diff --git a/ktor-features/ktor-pebble/jvm/src/io/ktor/pebble/RespondTemplate.kt b/ktor-features/ktor-pebble/jvm/src/io/ktor/pebble/RespondTemplate.kt new file mode 100644 index 000000000..932dde521 --- /dev/null +++ b/ktor-features/ktor-pebble/jvm/src/io/ktor/pebble/RespondTemplate.kt @@ -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, + locale: Locale? = null, + etag: String? = null, + contentType: ContentType = ContentType.Text.Html.withCharset( + Charsets.UTF_8 + ) +) = respond(PebbleContent(template, model, locale, etag, contentType)) diff --git a/ktor-features/ktor-pebble/jvm/test/io/ktor/pebble/PebbleTest.kt b/ktor-features/ktor-pebble/jvm/test/io/ktor/pebble/PebbleTest.kt new file mode 100644 index 000000000..e171c5fe7 --- /dev/null +++ b/ktor-features/ktor-pebble/jvm/test/io/ktor/pebble/PebbleTest.kt @@ -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("

Hello, 1

", lines[0]) + assertEquals("

Hello World!

", 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("

Hello, Anonymous

", lines[0]) + assertEquals("

Hi!

", 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("

Hello, 1

", lines[0]) + assertEquals("

Hello World!

", 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() = """ +

Hello, {{id}}

+

{{title}}

+ """.trimIndent() + private val TemplateWithoutPlaceholder: String + get() = """ +

Hello, Anonymous

+

Hi!

+ """.trimIndent() + } +} diff --git a/settings.gradle b/settings.gradle index 7b6a02ec8..3ee134cc5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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'