Skip to content

Commit

Permalink
Updates kotlin to 1.9.0, ktor to 2.3.2, adopts simple plugin to handl…
Browse files Browse the repository at this point in the history
…e json status code and body wrapping (#5)

* updates dependencies other than ktor

* updates kotlin to 1.9.0, updates ktor to last 1.x version, fixes resulting compile errors in MeterReadingControllerTest

* changes last junit anotation to kotlin test library one

* upgrades ktor to 2.3.2, does not yet adopt tests

* Adjusts the application tests to use the new ktor v2 framework and simplifies the controllers.

In particular, removes the custom Response class for a more idiomatic approach, using nullability to signal NotFound and turns validation errors into
IllegalArgumentExceptions and then into BadRequest rather than an InternalServerError for less surprising error code.

* adds ktor plugin to build to simplify further, adjusts README to reflect different name of fatJar

* Adds the detekt plugin with the standard rules and fixes all warnings to make the code more familiar in layout and look.

In particular:
* moves code to directories corresponding to package
* avoids wildcard imports
* pulls constants out and uses `const` keyword for them
* fixes assorted formatting nags
* removes magic numbers

Additionally:
* Simplifies the `ElectricityReadingsGenerator`

* Fixes some table formatting niggles in README

* Restores explicit http status block with optional body in output from ktor.

This had been modelled as a pseudo domain class (`Response`) that wrapped header and body along with an extension function to the `ApplicationCall`. However, that
approach conflated http concerns and the chosen approach of wrapping up the domain concerns in a sort of JSON "envelope" with "normal" code.

As these concerns are all about how the domain logic is mapping into specific outputs, we now go for a simple Ktor plugin that takes responsibility for:
* mapping `null` responses to 404
* wrapping simple status code responses so that they can be serialised to JSON without the `ContentNegotiation` plugin intervening
* wrapping normal responses into a `statusCode` and `body`
* turning `IllegalArgumentExceptions` into 400 with an additional error message

`ApplicationTest` is extended to validate these cases.
  • Loading branch information
jejking-tw authored Jul 17, 2023
1 parent 634e95f commit 9e8da33
Show file tree
Hide file tree
Showing 26 changed files with 388 additions and 318 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ To trial the new JOI software 5 people from the JOI accounts team have agreed to
data.

| User | Smart Meter ID | Power Supplier |
| ------- | --------------- | --------------------- |
|---------|-----------------|-----------------------|
| Sarah | `smart-meter-0` | Dr Evil's Dark Energy |
| Peter | `smart-meter-1` | The Green Eco |
| Charlie | `smart-meter-2` | Dr Evil's Dark Energy |
Expand Down Expand Up @@ -89,7 +89,7 @@ Run the application using Java and the executable JAR file produced by the Gradl
listening to port `8080`.

```console
$ java -jar build/libs/developer-joyofenergy-kotlin.jar
$ java -jar build/libs/developer-joyofenergy-kotlin-all.jar
```

### Run the tests
Expand Down Expand Up @@ -139,15 +139,15 @@ Example of body
Parameters

| Parameter | Description |
| -------------- | ----------------------------------------------------- |
|----------------|-------------------------------------------------------|
| `smartMeterId` | One of the smart meters' id listed above |
| `time` | The date/time (as epoch) when the _reading_ was taken |
| `reading` | The consumption in `kW` at the _time_ of the reading |

Example readings

| Date (`GMT`) | Epoch timestamp | Reading (`kW`) |
| ----------------- | --------------: | -------------: |
|-------------------|----------------:|---------------:|
| `2020-11-29 8:00` | 1606636800 | 0.0503 |
| `2020-11-29 8:01` | 1606636860 | 0.0621 |
| `2020-11-29 8:02` | 1606636920 | 0.0222 |
Expand Down Expand Up @@ -193,7 +193,7 @@ GET /readings/read/<smartMeterId>
Parameters

| Parameter | Description |
| -------------- | ---------------------------------------- |
|----------------|------------------------------------------|
| `smartMeterId` | One of the smart meters' id listed above |

Retrieving readings using CURL
Expand Down Expand Up @@ -246,7 +246,7 @@ GET /price-plans/compare-all/<smartMeterId>
Parameters

| Parameter | Description |
| -------------- | ---------------------------------------- |
|----------------|------------------------------------------|
| `smartMeterId` | One of the smart meters' id listed above |

Retrieving readings using CURL
Expand Down Expand Up @@ -284,7 +284,7 @@ GET /price-plans/recommend/<smartMeterId>[?limit=<limit>]
Parameters

| Parameter | Description |
| -------------- | ---------------------------------------------------- |
|----------------|------------------------------------------------------|
| `smartMeterId` | One of the smart meters' id listed above |
| `limit` | (Optional) limit the number of plans to be displayed |

Expand Down
29 changes: 15 additions & 14 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@

plugins {
application
kotlin("jvm")
id("io.ktor.plugin")
id("io.gitlab.arturbosch.detekt")
}

val kotlin_version: String by project
val ktor_version: String by project
val logback_version: String by project
val jackson_version: String by project
val strikt_version: String by project


val appMainClass = "io.ktor.server.netty.EngineMain"
val detekt_version: String by project

application {
mainClass.set(appMainClass)
mainClass.set("io.ktor.server.netty.EngineMain")
}

repositories {
Expand All @@ -26,12 +26,17 @@ dependencies {
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-jackson:$ktor_version")
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-jackson:$ktor_version")

implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jackson_version")

testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version")
testImplementation("io.ktor:ktor-server-tests:$ktor_version")
testImplementation("io.strikt:strikt-core:$strikt_version")
testImplementation("io.ktor:ktor-client-content-negotiation:$ktor_version")

detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detekt_version")
}

kotlin {
Expand All @@ -47,12 +52,8 @@ tasks {
}
}

tasks.jar {
manifest.attributes["Main-Class"] = appMainClass
val dependencies = configurations
.runtimeClasspath
.get()
.map(::zipTree)
from(dependencies)
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
ktor {
fatJar {
archiveFileName.set("developer-joyofenergy-kotlin-all.jar")
}
}
9 changes: 5 additions & 4 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
logback_version=1.2.1
ktor_version=1.3.0
kotlin_version=1.8.22
logback_version=1.4.8
ktor_version=2.3.2
kotlin_version=1.9.0
strikt_version=0.34.1
jackson_version=2.10.2
jackson_version=2.15.2
detekt_version=1.23.0
4 changes: 4 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
pluginManagement {
val kotlin_version: String by settings
val ktor_version: String by settings
val detekt_version: String by settings
plugins {
kotlin("jvm") version kotlin_version
id("io.ktor.plugin") version ktor_version
id("io.gitlab.arturbosch.detekt") version detekt_version
}
}

Expand Down
100 changes: 0 additions & 100 deletions src/main/kotlin/Application.kt

This file was deleted.

98 changes: 98 additions & 0 deletions src/main/kotlin/de/tw/energy/Application.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package de.tw.energy

import de.tw.energy.controllers.MeterReadingController
import de.tw.energy.controllers.PricePlanComparatorController
import de.tw.energy.domain.MeterReadings
import de.tw.energy.domain.PricePlan
import de.tw.energy.ktor.BodyAdapterPlugin
import de.tw.energy.services.AccountService
import de.tw.energy.services.MeterReadingService
import de.tw.energy.services.PricePlanService
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.jackson.jackson
import io.ktor.server.application.Application
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondNullable
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import java.math.BigDecimal

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
fun Application.module() {
install(BodyAdapterPlugin)
install(ContentNegotiation) {
jackson {
setup()
}
}

val meterReadingsService = MeterReadingService(mutableMapOf())
val pricePlanService = initPricePlanService(meterReadingsService)
val accountService = initAccountService()

routing {
route("/readings") {
val controller = MeterReadingController(meterReadingsService)

get("/read/{smartMeterId}") {
val smartMeterId = call.parameters["smartMeterId"] ?: ""
call.respondNullable(controller.readings(smartMeterId))
}

post("/store") {
val readings = call.receive<MeterReadings>()
try {
controller.storeReadings(readings)
call.respond(HttpStatusCode.OK)
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, e)
}
}
}

route("/price-plans") {
val controller = PricePlanComparatorController(
pricePlanService,
accountService
)

get("/compare-all/{smartMeterId}") {
val smartMeterId = call.parameters["smartMeterId"] ?: ""
call.respondNullable(controller.calculatedCostForEachPricePlan(smartMeterId))
}

get("/recommend/{smartMeterId}") {
val smartMeterId = call.parameters["smartMeterId"] ?: ""
val limit = call.request.queryParameters["limit"]
call.respondNullable(controller.recommendCheapestPricePlans(smartMeterId, limit?.toInt()))
}
}
}
}

private fun initAccountService() = AccountService(
mapOf(
SARAHS_SMART_METER_ID to MOST_EVIL_PRICE_PLAN_ID,
PETERS_SMART_METER_ID to RENEWABLES_PRICE_PLAN_ID,
CHARLIES_SMART_METER_ID to MOST_EVIL_PRICE_PLAN_ID,
ANDREAS_SMART_METER_ID to STANDARD_PRICE_PLAN_ID,
ALEXS_SMART_METER_ID to RENEWABLES_PRICE_PLAN_ID
)
)

private fun initPricePlanService(meterReadingsService: MeterReadingService) = PricePlanService(
listOf(
PricePlan(MOST_EVIL_PRICE_PLAN_ID, DR_EVILS_DARK_ENERGY_ENERGY_SUPPLIER, BigDecimal.TEN, listOf()),
PricePlan(RENEWABLES_PRICE_PLAN_ID, THE_GREEN_ECO_ENERGY_SUPPLIER, BigDecimal(2), listOf()),
PricePlan(STANDARD_PRICE_PLAN_ID, POWER_FOR_EVERYONE_ENERGY_SUPPLIER, BigDecimal.ONE, listOf())
),
meterReadingsService
)
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package de.tw.energy
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.registerKotlinModule

fun ObjectMapper.setup(): ObjectMapper {
enable(SerializationFeature.INDENT_OUTPUT)
enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
registerModule(JavaTimeModule())
registerKotlinModule()
return this
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,21 @@ package de.tw.energy.controllers

import de.tw.energy.domain.ElectricityReading
import de.tw.energy.domain.MeterReadings
import de.tw.energy.domain.Response
import de.tw.energy.services.MeterReadingService

const val INVALID_READINGS_MESSAGE = "Readings supplied are invalid - they must not be blank or empty"

class MeterReadingController(private val readingService: MeterReadingService) {
fun readings(smartMeterId: String): Response<List<ElectricityReading>> {
return readingService[smartMeterId]?.let {
Response.body(it)
} ?: Response.notFound()
fun readings(smartMeterId: String): List<ElectricityReading>? {
return readingService[smartMeterId]
}

fun storeReadings(readings: MeterReadings): Response<Nothing> {
if (!readings.isValid())
return Response.internalError()
fun storeReadings(readings: MeterReadings) {
require(readings.isValid()) {
INVALID_READINGS_MESSAGE
}

readingService.store(readings.smartMeterId, readings.readings)
return Response.empty()
}

private fun MeterReadings.isValid() = smartMeterId.isNotBlank() && readings.isNotEmpty()
Expand Down
Loading

0 comments on commit 9e8da33

Please sign in to comment.