From ea7acd31eaccdf148a994afc35432635c48d8074 Mon Sep 17 00:00:00 2001 From: "Erdle, Tobias" Date: Fri, 9 Nov 2018 18:49:51 +0100 Subject: [PATCH] Add Mustache templating feature (#713) --- ktor-features/ktor-mustache/build.gradle | 4 + .../src/io/ktor/mustache/Mustache.kt | 90 ++++++++++ .../src/io/ktor/mustache/RespondTemplate.kt | 21 +++ .../test-resources/withPlaceholder.mustache | 2 + .../withoutPlaceholder.mustache | 2 + .../test/io/ktor/mustache/MustacheTest.kt | 161 ++++++++++++++++++ settings.gradle | 1 + 7 files changed, 281 insertions(+) create mode 100644 ktor-features/ktor-mustache/build.gradle create mode 100644 ktor-features/ktor-mustache/src/io/ktor/mustache/Mustache.kt create mode 100644 ktor-features/ktor-mustache/src/io/ktor/mustache/RespondTemplate.kt create mode 100644 ktor-features/ktor-mustache/test-resources/withPlaceholder.mustache create mode 100644 ktor-features/ktor-mustache/test-resources/withoutPlaceholder.mustache create mode 100644 ktor-features/ktor-mustache/test/io/ktor/mustache/MustacheTest.kt diff --git a/ktor-features/ktor-mustache/build.gradle b/ktor-features/ktor-mustache/build.gradle new file mode 100644 index 00000000000..6f3aaf274f6 --- /dev/null +++ b/ktor-features/ktor-mustache/build.gradle @@ -0,0 +1,4 @@ +description = '' +dependencies { + compile group: 'com.github.spullara.mustache.java', name: 'compiler', version: '0.9.5' +} diff --git a/ktor-features/ktor-mustache/src/io/ktor/mustache/Mustache.kt b/ktor-features/ktor-mustache/src/io/ktor/mustache/Mustache.kt new file mode 100644 index 00000000000..ad4aacd89cb --- /dev/null +++ b/ktor-features/ktor-mustache/src/io/ktor/mustache/Mustache.kt @@ -0,0 +1,90 @@ +package io.ktor.mustache + +import com.github.mustachejava.DefaultMustacheFactory +import com.github.mustachejava.MustacheFactory +import io.ktor.application.ApplicationCallPipeline +import io.ktor.application.ApplicationFeature +import io.ktor.http.ContentType +import io.ktor.http.charset +import io.ktor.http.content.EntityTagVersion +import io.ktor.http.content.OutgoingContent +import io.ktor.http.content.versions +import io.ktor.http.withCharset +import io.ktor.response.ApplicationSendPipeline +import io.ktor.util.AttributeKey +import io.ktor.util.cio.bufferedWriter +import kotlinx.coroutines.io.ByteWriteChannel + +/** + * Response content which could be used to respond [ApplicationCalls] like `call.respond(MustacheContent(...)) + * + * @param template name of the template to be resolved by Mustache + * @param model which is passed into the template + * @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 MustacheContent( + val template: String, + val model: Any?, + val etag: String? = null, + val contentType: ContentType = ContentType.Text.Html.withCharset(Charsets.UTF_8) +) + +/** + * Feature for providing Mustache templates as [MustacheContent] + */ +class Mustache(private val mustacheFactory: MustacheFactory) { + + companion object Feature : ApplicationFeature { + override val key = AttributeKey("mustache") + + override fun install(pipeline: ApplicationCallPipeline, configure: MustacheFactory.() -> Unit): Mustache { + val mustacheFactory = DefaultMustacheFactory().apply(configure) + val feature = Mustache(mustacheFactory) + + pipeline.sendPipeline.intercept(ApplicationSendPipeline.Transform) { value -> + if (value is MustacheContent) { + val response = feature.process(value) + proceedWith(response) + } + } + + return feature + } + } + + private fun process(content: MustacheContent): MustacheOutgoingContent { + return MustacheOutgoingContent( + mustacheFactory.compile(content.template), + content.model, + content.etag, + content.contentType + ) + } + + /** + * Content which is responded when Mustache templates are rendered. + * + * @param template the compiled [com.github.mustachejava.Mustache] template + * @param model the model provided into the template + * @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 MustacheOutgoingContent( + val template: com.github.mustachejava.Mustache, + val model: Any?, + etag: String?, + override val contentType: ContentType + ) : OutgoingContent.WriteChannelContent() { + override suspend fun writeTo(channel: ByteWriteChannel) { + channel.bufferedWriter(contentType.charset() ?: Charsets.UTF_8).use { + template.execute(it, model) + } + } + + init { + if (etag != null) + versions += EntityTagVersion(etag) + } + } +} diff --git a/ktor-features/ktor-mustache/src/io/ktor/mustache/RespondTemplate.kt b/ktor-features/ktor-mustache/src/io/ktor/mustache/RespondTemplate.kt new file mode 100644 index 00000000000..84c3c8bad1e --- /dev/null +++ b/ktor-features/ktor-mustache/src/io/ktor/mustache/RespondTemplate.kt @@ -0,0 +1,21 @@ +package io.ktor.mustache + +import io.ktor.application.ApplicationCall +import io.ktor.http.ContentType +import io.ktor.http.withCharset +import io.ktor.response.respond + + +/** + * Respond with the specified [template] passing [model] + * + * @see MustacheContent + */ +suspend fun ApplicationCall.respondTemplate( + template: String, + model: Any? = null, + etag: String? = null, + contentType: ContentType = ContentType.Text.Html.withCharset( + Charsets.UTF_8 + ) +) = respond(MustacheContent(template, model, etag, contentType)) diff --git a/ktor-features/ktor-mustache/test-resources/withPlaceholder.mustache b/ktor-features/ktor-mustache/test-resources/withPlaceholder.mustache new file mode 100644 index 00000000000..2a5c8b59c95 --- /dev/null +++ b/ktor-features/ktor-mustache/test-resources/withPlaceholder.mustache @@ -0,0 +1,2 @@ +

Hello, {{id}}

+

{{title}}

diff --git a/ktor-features/ktor-mustache/test-resources/withoutPlaceholder.mustache b/ktor-features/ktor-mustache/test-resources/withoutPlaceholder.mustache new file mode 100644 index 00000000000..aec6ccaf4c7 --- /dev/null +++ b/ktor-features/ktor-mustache/test-resources/withoutPlaceholder.mustache @@ -0,0 +1,2 @@ +

Hello, Anonymous

+

Hi!

diff --git a/ktor-features/ktor-mustache/test/io/ktor/mustache/MustacheTest.kt b/ktor-features/ktor-mustache/test/io/ktor/mustache/MustacheTest.kt new file mode 100644 index 00000000000..f1914e47616 --- /dev/null +++ b/ktor-features/ktor-mustache/test/io/ktor/mustache/MustacheTest.kt @@ -0,0 +1,161 @@ +package io.ktor.mustache + +import com.github.mustachejava.DefaultMustacheFactory +import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.application.install +import io.ktor.features.Compression +import io.ktor.features.ConditionalHeaders +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.withCharset +import io.ktor.response.respond +import io.ktor.routing.get +import io.ktor.routing.routing +import io.ktor.server.testing.handleRequest +import io.ktor.server.testing.withTestApplication +import org.junit.Test +import java.util.zip.GZIPInputStream +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class MustacheTest { + + @Test + fun `Fill template and expect correct rendered content`() { + withTestApplication { + application.setupMustache() + application.install(ConditionalHeaders) + + application.routing { + get("/") { + call.respond(MustacheContent(TemplateWithPlaceholder, DefaultModel, "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.setupMustache() + application.install(ConditionalHeaders) + + application.routing { + get("/") { + call.respond(MustacheContent(TemplateWithPlaceholder, DefaultModel, "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.setupMustache() + application.install(ConditionalHeaders) + + application.routing { + get("/") { + call.respond(MustacheContent(TemplateWithPlaceholder, DefaultModel, "e")) + } + } + + assertEquals("e", handleRequest(HttpMethod.Get, "/").response.headers[HttpHeaders.ETag]) + } + } + + + @Test + fun `Render empty model`() { + withTestApplication { + application.setupMustache() + application.install(ConditionalHeaders) + + application.routing { + get("/") { + call.respond(MustacheContent(TemplateWithoutPlaceholder, null, "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.setupMustache() + application.install(Compression) + application.install(ConditionalHeaders) + + application.routing { + get("/") { + call.respondTemplate(TemplateWithPlaceholder, DefaultModel, "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.setupMustache() + application.install(ConditionalHeaders) + + application.routing { + + get("/") { + call.respond(MustacheContent(TemplateWithPlaceholder, DefaultModel)) + } + } + + assertEquals(null, handleRequest(HttpMethod.Get, "/").response.headers[HttpHeaders.ETag]) + } + } + + private fun Application.setupMustache() { + install(Mustache) { + DefaultMustacheFactory() + } + } + + companion object { + private val DefaultModel = mapOf("id" to 1, "title" to "Hello World!") + + private val TemplateWithPlaceholder = "withPlaceholder.mustache" + private val TemplateWithoutPlaceholder = "withoutPlaceholder.mustache" + } +} diff --git a/settings.gradle b/settings.gradle index 9ef3c900622..c1ed216f675 100644 --- a/settings.gradle +++ b/settings.gradle @@ -59,6 +59,7 @@ includeEx ':ktor-client:ktor-client-features:ktor-client-logging:ktor-client-log includeEx ':ktor-client:ktor-client-features:ktor-client-logging:ktor-client-logging-js' includeEx ':ktor-client:ktor-client-features:ktor-client-logging:ktor-client-logging-ios' includeEx ':ktor-features:ktor-freemarker' +includeEx ':ktor-features:ktor-mustache' includeEx ':ktor-features:ktor-velocity' includeEx ':ktor-features:ktor-gson' includeEx ':ktor-features:ktor-jackson'