Skip to content

Commit

Permalink
Add Mustache templating feature (#713)
Browse files Browse the repository at this point in the history
  • Loading branch information
Erdle, Tobias authored and Sergey Mashkov committed Dec 13, 2018
1 parent b93daad commit 82381e8
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 0 deletions.
4 changes: 4 additions & 0 deletions ktor-features/ktor-mustache/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
description = ''
dependencies {
compile group: 'com.github.spullara.mustache.java', name: 'compiler', version: '0.9.5'
}
90 changes: 90 additions & 0 deletions ktor-features/ktor-mustache/src/io/ktor/mustache/Mustache.kt
Original file line number Diff line number Diff line change
@@ -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<ApplicationCallPipeline, MustacheFactory, Mustache> {
override val key = AttributeKey<Mustache>("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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>Hello, {{id}}</p>
<h1>{{title}}</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>Hello, Anonymous</p>
<h1>Hi!</h1>
161 changes: 161 additions & 0 deletions ktor-features/ktor-mustache/test/io/ktor/mustache/MustacheTest.kt
Original file line number Diff line number Diff line change
@@ -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("<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.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("<p>Hello, Anonymous</p>", lines[0])
assertEquals("<h1>Hi!</h1>", 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("<p>Hello, 1</p>", lines[0])
assertEquals("<h1>Hello World!</h1>", 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"
}
}
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit 82381e8

Please sign in to comment.