Skip to content

Commit f77b0aa

Browse files
committed
Implement web ui using server templating
The only intention of this UI is to demonstrate how the Bank contract can be used to drive its testing, expanding its coverage to the highest level of abstraction. As it's not meant to be production ready but just to serve as example, the tests run in memory rather than against actual running services. It has the drawback of coupling the web and http projects more than it needs to. On the implementation, it's important that the hbs template is named after the ViewModel data class and is located in the same package structure for http4k to recognise it.
1 parent 990d9d9 commit f77b0aa

6 files changed

Lines changed: 195 additions & 1 deletion

File tree

settings.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
rootProject.name = 'bank'
22

33
include ':domain'
4-
include ':http'
4+
include ':http'
5+
include ':web'

web/build.gradle

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
dependencies {
2+
implementation project(':domain')
3+
implementation project(':http')
4+
implementation platform('org.http4k:http4k-bom:3.284.0')
5+
implementation "org.http4k:http4k-core"
6+
implementation "org.http4k:http4k-template-handlebars"
7+
8+
testImplementation testFixtures(project(':domain'))
9+
testImplementation platform('org.http4k:http4k-bom:3.284.0')
10+
testImplementation 'org.http4k:http4k-testing-webdriver'
11+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package lmirabal.bank.web
2+
3+
import lmirabal.bank.Bank
4+
import lmirabal.bank.model.Amount
5+
import lmirabal.bank.model.BankAccount
6+
import org.http4k.core.Body
7+
import org.http4k.core.ContentType.Companion.TEXT_HTML
8+
import org.http4k.core.HttpHandler
9+
import org.http4k.core.Method.GET
10+
import org.http4k.core.Method.POST
11+
import org.http4k.core.Response
12+
import org.http4k.core.Status
13+
import org.http4k.core.Status.Companion.SEE_OTHER
14+
import org.http4k.core.with
15+
import org.http4k.routing.bind
16+
import org.http4k.routing.routes
17+
import org.http4k.template.HandlebarsTemplates
18+
import org.http4k.template.ViewModel
19+
import org.http4k.template.viewModel
20+
import java.math.BigDecimal
21+
22+
fun bankWeb(bank: Bank): HttpHandler {
23+
return routes(
24+
"/" bind routes(
25+
POST to CreateAccount(bank),
26+
GET to ListAccounts(bank)
27+
)
28+
)
29+
}
30+
31+
object CreateAccount {
32+
operator fun invoke(bank: Bank): HttpHandler = {
33+
bank.createAccount()
34+
Response(SEE_OTHER).header("location", "/")
35+
}
36+
}
37+
38+
object ListAccounts {
39+
private val templates = HandlebarsTemplates().CachingClasspath()
40+
private val viewLens = Body.viewModel(templates, TEXT_HTML).toLens()
41+
42+
operator fun invoke(client: Bank): HttpHandler = {
43+
val accounts = client.listAccounts().map { account -> account.toViewModel() }
44+
Response(Status.OK).with(viewLens of BankAccountListView(accounts))
45+
}
46+
47+
private fun BankAccount.toViewModel() = BankAccountView(id.value.toString(), balance.format())
48+
49+
private fun Amount.format() = BigDecimal(minorUnits).movePointLeft(2).toPlainString()
50+
}
51+
52+
data class BankAccountView(val id: String, val balance: String)
53+
data class BankAccountListView(val accounts: List<BankAccountView>) : ViewModel
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<html>
2+
<head>
3+
<title>Bank</title>
4+
<style>
5+
table, th, td {
6+
border: 1px solid black;
7+
border-collapse: collapse;
8+
}
9+
th, td {
10+
padding: 5px;
11+
}
12+
th {
13+
text-align: left;
14+
}
15+
16+
17+
</style>
18+
</head>
19+
<body>
20+
<div>
21+
<h2>Create Account</h2>
22+
<form method="POST" action="/">
23+
<input type="submit" id="create-account" value="Create">
24+
</form>
25+
<h2>Bank Accounts</h2>
26+
<table>
27+
<thead>
28+
<tr>
29+
<th>ID</th>
30+
<th>Balance</th>
31+
</tr>
32+
</thead>
33+
<tbody>
34+
{{#accounts}}
35+
<tr>
36+
<td>{{id}}</td>
37+
<td>{{balance}}</td>
38+
</tr>
39+
{{/accounts}}
40+
</tbody>
41+
</table>
42+
</div>
43+
</body>
44+
</html>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package lmirabal.bank.web
2+
3+
import lmirabal.bank.Bank
4+
import lmirabal.bank.BankTest
5+
import lmirabal.bank.http.BankHttpClient
6+
import lmirabal.bank.http.bankHttp
7+
import lmirabal.bank.model.Amount
8+
import lmirabal.bank.model.BankAccount
9+
import lmirabal.bank.model.BankAccountId
10+
import lmirabal.selenium.getElement
11+
import lmirabal.selenium.getTableColumn
12+
import lmirabal.selenium.getTableRows
13+
import org.http4k.core.HttpHandler
14+
import org.http4k.webdriver.Http4kWebDriver
15+
import org.openqa.selenium.By
16+
import org.openqa.selenium.SearchContext
17+
import org.openqa.selenium.WebElement
18+
import java.math.BigDecimal
19+
import java.util.UUID
20+
21+
class BankWebTest : BankTest() {
22+
private val http: HttpHandler = bankHttp()
23+
private val httpClient: Bank = BankHttpClient(http)
24+
private val web: HttpHandler = bankWeb(httpClient)
25+
override val bank: Bank = BankWebDriver(web)
26+
}
27+
28+
class BankWebDriver(web: HttpHandler) : Bank {
29+
private val driver = Http4kWebDriver(web)
30+
31+
override fun createAccount(): BankAccount {
32+
driver.navigate().to("/")
33+
driver.getElement(By.id("create-account")).submit()
34+
35+
return driver.getBankAccounts().last()
36+
}
37+
38+
override fun listAccounts(): List<BankAccount> {
39+
return driver.getBankAccounts()
40+
}
41+
42+
private fun SearchContext.getBankAccounts(): List<BankAccount> {
43+
return getTableRows().map { row -> row.toBankAccount() }
44+
}
45+
46+
private fun WebElement.toBankAccount(): BankAccount {
47+
fun WebElement.getBankAccountId(): BankAccountId {
48+
val idText = getTableColumn(ID_INDEX)
49+
return BankAccountId(UUID.fromString(idText))
50+
}
51+
52+
fun WebElement.getBalance(): Amount {
53+
val balanceTextInMajorUnits = getTableColumn(BALANCE_INDEX)
54+
val balanceInMinorUnits = BigDecimal(balanceTextInMajorUnits).movePointRight(2).toLong()
55+
return Amount(balanceInMinorUnits)
56+
}
57+
58+
return BankAccount(getBankAccountId(), getBalance())
59+
}
60+
61+
companion object {
62+
private const val ID_INDEX = 0
63+
private const val BALANCE_INDEX = 1
64+
}
65+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package lmirabal.selenium
2+
3+
import org.openqa.selenium.By
4+
import org.openqa.selenium.SearchContext
5+
import org.openqa.selenium.WebElement
6+
7+
fun SearchContext.getTableRows(): List<WebElement> {
8+
val accountsTable = getElement(By.tagName("tbody"))
9+
return accountsTable.getElements(By.cssSelector("tr"))
10+
}
11+
12+
fun SearchContext.getElement(by: By): WebElement =
13+
findElement(by) ?: throw AssertionError("Could not find element $by")
14+
15+
fun SearchContext.getElements(by: By): List<WebElement> =
16+
findElements(by) ?: throw AssertionError("Could not find element $by")
17+
18+
fun WebElement.getTableColumn(index: Int): String {
19+
return getElement(By.cssSelector("td:nth-child(${index + 1})")).text
20+
}

0 commit comments

Comments
 (0)