Skip to content

Commit

Permalink
TodoMVC kotlin ktor (#3651)
Browse files Browse the repository at this point in the history
Part of #3611

---------

Co-authored-by: Li Haoyi <haoyi.sg@gmail.com>
  • Loading branch information
javimartinez and lihaoyi authored Oct 5, 2024
1 parent d43417e commit d0cc6f8
Show file tree
Hide file tree
Showing 11 changed files with 406 additions and 2 deletions.
2 changes: 1 addition & 1 deletion example/kotlinlib/web/1-hello-ktor/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ object `package` extends RootModule with KotlinModule {

> mill runBackground

> curl http://localhost:8080
> curl http://localhost:8090
...<h1>Hello, World!</h1>...

> mill clean runBackground
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import io.ktor.server.response.*
import io.ktor.server.routing.*

fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
embeddedServer(Netty, port = 8090, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}

Expand Down
58 changes: 58 additions & 0 deletions example/kotlinlib/web/2-todo-ktor/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//// SNIPPET:BUILD
package build
import mill._, kotlinlib._

object `package` extends RootModule with KotlinModule {

def kotlinVersion = "1.9.24"

def mainClass = Some("com.example.TodoMVCApplicationKt")

val ktorVersion = "2.3.12"
val exposedVersion = "0.53.0"

def ivyDeps = Agg(
ivy"io.ktor:ktor-server-core-jvm:$ktorVersion",
ivy"io.ktor:ktor-server-netty-jvm:$ktorVersion",
ivy"org.jetbrains.exposed:exposed-core:$exposedVersion",
ivy"org.jetbrains.exposed:exposed-jdbc:$exposedVersion",
ivy"com.h2database:h2:2.2.224",
ivy"io.ktor:ktor-server-webjars-jvm:$ktorVersion",
ivy"org.webjars:jquery:3.2.1",
ivy"io.ktor:ktor-server-thymeleaf-jvm:$ktorVersion",
ivy"org.webjars:webjars-locator:0.41",
ivy"org.webjars.npm:todomvc-common:1.0.5",
ivy"org.webjars.npm:todomvc-app-css:2.4.1",
ivy"ch.qos.logback:logback-classic:1.4.14"
)

object test extends KotlinTests with TestModule.Junit5 {
def ivyDeps = super.ivyDeps() ++ Agg(
ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1",
ivy"io.ktor:ktor-server-test-host-jvm:2.3.12"
)
}
}


// This example implementing the well known
// https://todomvc.com/[TodoMVC] example app using Kotlin and Ktor. Apart from running a webserver,
// this example also demonstrates:

// * Serving HTML templates using Thymeleaf
// * Serving static Javascript and CSS using Webjars
// * Querying a SQL database using Exposed
// * Testing using a H2 in-memory database

/** Usage

> mill test

> mill runBackground

> curl http://localhost:8091
...<h1>todos</h1>...

> mill clean runBackground

*/
12 changes: 12 additions & 0 deletions example/kotlinlib/web/2-todo-ktor/resources/logback.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="trace">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" lang="en">
<li th:fragment="todoItem(item)" th:classappend="${item.completed?'completed':''}">
<div class="view">
<form method="post" th:action="@{~/{id}/toggle(id=${item.id})}">
<input class="toggle" type="checkbox"
onchange="this.form.submit()"
th:attrappend="checked=${item.completed?'true':null}">
<label th:text="${item.title}">Taste JavaScript</label>
</form>
<form method="post" th:action="@{~/{id}/delete(id=${item.id})}">
<button class="destroy"></button>
</form>
</div>
<input class="edit" value="Create a TodoMVC template">
</li>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Template • TodoMVC</title>
<link rel="stylesheet" th:href="@{~/webjars/todomvc-common/base.css}">
<link rel="stylesheet" th:href="@{~/webjars/todomvc-app-css/index.css}">
</head>
<body>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<p></p>
<form th:action="@{~/save}" method="post" th:object="${item}">
<input class="new-todo" placeholder="What needs to be done?" autofocus
id="item" name="title">
</form>
</header>
<!-- This section should be hidden by default and shown when there are todos -->
<section class="main" th:if="${totalItemCount > 0}">
<form method="post" th:action="@{~/toggle-all}">
<input id="toggle-all" class="toggle-all" type="checkbox"
onclick="this.form.submit()">
<label for="toggle-all">Mark all as complete</label>
</form>
<ul class="todo-list" th:remove="all-but-first">
<li th:insert="fragments :: todoItem(${item})" th:each="item : ${todoItems}" th:remove="tag">
</li>
<!-- These are here just to show the structure of the list items -->
<!-- List items should get the class `editing` when editing and `completed` when marked as completed -->
<li class="completed">
<div class="view">
<input class="toggle" type="checkbox" checked>
<label>Taste JavaScript</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Create a TodoMVC template">
</li>
<li>
<div class="view">
<input class="toggle" type="checkbox">
<label>Buy a unicorn</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Rule the web">
</li>
</ul>
</section>
<!-- This footer should be hidden by default and shown when there are todos -->
<footer class="footer" th:if="${totalItemCount > 0}">
<th:block th:unless="${activeItemCount == 1}">
<span class="todo-count"><strong th:text="${activeItemCount}">0</strong> items left</span>
</th:block>
<th:block th:if="${activeItemCount == 1}">
<span class="todo-count"><strong>1</strong> item left</span>
</th:block>
<ul class="filters">
<li>
<a th:href="@{~/}"
th:classappend="${todoFilter == 'ALL'?'selected':''}">All</a>
</li>
<li>
<a th:href="@{~/active}"
th:classappend="${todoFilter == 'ACTIVE'?'selected':''}">Active</a>
</li>
<li>
<a th:href="@{~/completed}"
th:classappend="${todoFilter == 'COMPLETED'?'selected':''}">Completed</a>
</li>
</ul>
<form th:action="@{~/completed/delete}"
th:if="${numberOfCompletedItems > 0}">
<button class="clear-completed">Clear completed</button>
</form>
</footer>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
</footer>
</body>
</html>
6 changes: 6 additions & 0 deletions example/kotlinlib/web/2-todo-ktor/src/com/example/TodoItem.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example

import java.util.UUID

data class TodoItem(val id: UUID , val title: String, val completed: Boolean = false)

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.example

import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.thymeleaf.*
import io.ktor.server.util.*
import java.util.*

enum class ListFilter {
ALL,
ACTIVE,
COMPLETED
}

data class TodoItemFormData(var title: String? = null)

fun modelContent(
todos: List<TodoItem>,
filter: ListFilter
): Map<String, Any> {
val activeItemCount = todos.count { !it.completed }
val numberOfCompletedItems = todos.count { it.completed }

val items = when (filter) {
ListFilter.ALL -> todos
ListFilter.ACTIVE -> todos.filterNot { it.completed }
ListFilter.COMPLETED -> todos.filter { it.completed }
}

return mapOf(
"item" to TodoItemFormData(),
"todoItems" to items,
"totalItemCount" to todos.size,
"activeItemCount" to activeItemCount,
"numberOfCompletedItems" to numberOfCompletedItems,
"filter" to filter.name
)
}

fun Application.configureRoutes(repository: TodoItemRepository) {
routing {
get("/") {
val todos = repository.findAll()
call.respond(ThymeleafContent("index", modelContent(todos, ListFilter.ALL)))
}
get("/active") {
val todos = repository.findAll()
call.respond(ThymeleafContent("index", modelContent(todos, ListFilter.ACTIVE)))
}
get("/completed") {
val todos = repository.findAll()
call.respond(ThymeleafContent("index", modelContent(todos, ListFilter.COMPLETED)))
}
get("/completed/delete") {
repository.findAll().filter { it.completed }
.forEach { repository.deleteById(it.id) }
call.respondRedirect("/")
}
post("/save") {
val title = call.receiveParameters().getOrFail("title")
repository.save(TodoItem(UUID.randomUUID(), title))
call.respondRedirect("/")
}
post("/{id}/delete") {
val id = call.parameters.getOrFail("id")
repository.deleteById(UUID.fromString(id))
call.respondRedirect("/")
}
post("/{id}/toggle") {
val id = call.parameters.getOrFail("id")
val item = repository.findById(UUID.fromString(id))
repository.save(item.copy(completed = !item.completed))
call.respondRedirect("/")
}
post("/toggle-all") {
repository.findAll().map { it.copy(completed = !it.completed) }
.forEach { repository.save(it) }
call.respondRedirect("/")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.example

import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.UUID

interface TodoItemRepository {
suspend fun save(item: TodoItem)
suspend fun findById(id: UUID): TodoItem
suspend fun findAll(): List<TodoItem>
suspend fun deleteById(id: UUID)
}

class TodoItemNotFound(id: String) : Exception("TodoItem $id not found")

class TodoItemRepositoryImpl(database: Database) : TodoItemRepository {

object TodoItems : Table() {
val id = uuid("id")
val title = varchar("title", length = 50)
val completed = bool("completed")

override val primaryKey = PrimaryKey(id)
}

init {
transaction(database) {
SchemaUtils.create(TodoItems)
}
}

override suspend fun save(item: TodoItem): Unit = query {
TodoItems.upsert {
it[id] = item.id
it[title] = item.title
it[completed] = item.completed
}
}

override suspend fun findById(id: UUID): TodoItem = query {
TodoItems.selectAll().where(TodoItems.id eq id)
.map { TodoItem(it[TodoItems.id], it[TodoItems.title], it[TodoItems.completed]) }
.singleOrNull() ?: throw TodoItemNotFound(id.toString())
}


override suspend fun findAll(): List<TodoItem> = query {
TodoItems.selectAll()
.map { TodoItem(it[TodoItems.id], it[TodoItems.title], it[TodoItems.completed]) }
}


override suspend fun deleteById(id: UUID): Unit = query {
TodoItems.deleteWhere { TodoItems.id eq id }
}

private suspend fun <T> query(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.thymeleaf.*
import io.ktor.server.webjars.*
import org.jetbrains.exposed.sql.Database
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver

fun main(args: Array<String>) {
val database = Database.connect("jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1;", "org.h2.Driver")
val todoItemRepository = TodoItemRepositoryImpl(database)
embeddedServer(Netty, port = 8091, host = "0.0.0.0"){
app(todoItemRepository)
}.start(wait = true)
}

fun Application.configureTemplating() {
install(Thymeleaf) {
setTemplateResolver(ClassLoaderTemplateResolver().apply {
prefix = "templates/thymeleaf/"
suffix = ".html"
characterEncoding = "utf-8"
})
}
}

fun Application.configureWebjars() {
install(Webjars) {
path = "/webjars" //defaults to /webjars
}
}

fun Application.app(todoItemRepository: TodoItemRepository) {
configureTemplating()
configureWebjars()
configureRoutes(todoItemRepository)
}
Loading

0 comments on commit d0cc6f8

Please sign in to comment.