-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Mustache templating feature (#713)
- Loading branch information
Erdle, Tobias
authored and
Sergey Mashkov
committed
Dec 13, 2018
1 parent
b93daad
commit 82381e8
Showing
7 changed files
with
281 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
90
ktor-features/ktor-mustache/src/io/ktor/mustache/Mustache.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
21 changes: 21 additions & 0 deletions
21
ktor-features/ktor-mustache/src/io/ktor/mustache/RespondTemplate.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) |
2 changes: 2 additions & 0 deletions
2
ktor-features/ktor-mustache/test-resources/withPlaceholder.mustache
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
<p>Hello, {{id}}</p> | ||
<h1>{{title}}</h1> |
2 changes: 2 additions & 0 deletions
2
ktor-features/ktor-mustache/test-resources/withoutPlaceholder.mustache
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
161
ktor-features/ktor-mustache/test/io/ktor/mustache/MustacheTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters