From 9e8da334644e1ece032c33b4e74dcaf9bcffc170 Mon Sep 17 00:00:00 2001 From: John King <53827445+jejking-tw@users.noreply.github.com> Date: Mon, 17 Jul 2023 16:59:21 +0200 Subject: [PATCH] Updates kotlin to 1.9.0, ktor to 2.3.2, adopts simple plugin to handle 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. --- README.md | 14 +- build.gradle.kts | 29 ++-- gradle.properties | 9 +- settings.gradle.kts | 4 + src/main/kotlin/Application.kt | 100 ------------ src/main/kotlin/de/tw/energy/Application.kt | 98 +++++++++++ .../kotlin/{ => de/tw/energy}/Fixtures.kt | 0 .../kotlin/{ => de/tw/energy}/ObjectMapper.kt | 2 + .../controllers/MeterReadingController.kt | 17 +- .../PricePlanComparatorController.kt | 21 +-- .../tw/energy}/domain/ElectricityReading.kt | 0 .../tw/energy}/domain/MeterReadings.kt | 0 .../{ => de/tw/energy}/domain/PricePlan.kt | 0 .../generator/ElectricityReadingsGenerator.kt | 15 +- .../de/tw/energy/ktor/BodyAdapterPlugin.kt | 38 +++++ .../tw/energy}/services/AccountService.kt | 0 .../energy}/services/MeterReadingService.kt | 0 .../tw/energy}/services/PricePlanService.kt | 12 +- src/main/kotlin/domain/Response.kt | 30 ---- src/test/kotlin/ApplicationTest.kt | 87 ---------- .../kotlin/de/tw/energy/ApplicationTest.kt | 154 ++++++++++++++++++ .../controllers/MeterReadingControllerTest.kt | 29 ++-- .../PricePlanComparatorControllerTest.kt | 35 ++-- .../tw/energy}/domain/PricePlanTest.kt | 5 +- .../tw/energy}/services/AccountServiceTest.kt | 7 +- .../services/MeterReadingServiceTest.kt | 0 26 files changed, 388 insertions(+), 318 deletions(-) delete mode 100644 src/main/kotlin/Application.kt create mode 100644 src/main/kotlin/de/tw/energy/Application.kt rename src/main/kotlin/{ => de/tw/energy}/Fixtures.kt (100%) rename src/main/kotlin/{ => de/tw/energy}/ObjectMapper.kt (81%) rename src/main/kotlin/{ => de/tw/energy}/controllers/MeterReadingController.kt (51%) rename src/main/kotlin/{ => de/tw/energy}/controllers/PricePlanComparatorController.kt (71%) rename src/main/kotlin/{ => de/tw/energy}/domain/ElectricityReading.kt (100%) rename src/main/kotlin/{ => de/tw/energy}/domain/MeterReadings.kt (100%) rename src/main/kotlin/{ => de/tw/energy}/domain/PricePlan.kt (100%) rename src/main/kotlin/{ => de/tw/energy}/generator/ElectricityReadingsGenerator.kt (56%) create mode 100644 src/main/kotlin/de/tw/energy/ktor/BodyAdapterPlugin.kt rename src/main/kotlin/{ => de/tw/energy}/services/AccountService.kt (100%) rename src/main/kotlin/{ => de/tw/energy}/services/MeterReadingService.kt (100%) rename src/main/kotlin/{ => de/tw/energy}/services/PricePlanService.kt (87%) delete mode 100644 src/main/kotlin/domain/Response.kt delete mode 100644 src/test/kotlin/ApplicationTest.kt create mode 100644 src/test/kotlin/de/tw/energy/ApplicationTest.kt rename src/test/kotlin/{ => de/tw/energy}/controllers/MeterReadingControllerTest.kt (71%) rename src/test/kotlin/{ => de/tw/energy}/controllers/PricePlanComparatorControllerTest.kt (79%) rename src/test/kotlin/{ => de/tw/energy}/domain/PricePlanTest.kt (91%) rename src/test/kotlin/{ => de/tw/energy}/services/AccountServiceTest.kt (61%) rename src/test/kotlin/{ => de/tw/energy}/services/MeterReadingServiceTest.kt (100%) diff --git a/README.md b/README.md index 9e103f7..826bc3d 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -139,7 +139,7 @@ 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 | @@ -147,7 +147,7 @@ Parameters 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 | @@ -193,7 +193,7 @@ GET /readings/read/ Parameters | Parameter | Description | -| -------------- | ---------------------------------------- | +|----------------|------------------------------------------| | `smartMeterId` | One of the smart meters' id listed above | Retrieving readings using CURL @@ -246,7 +246,7 @@ GET /price-plans/compare-all/ Parameters | Parameter | Description | -| -------------- | ---------------------------------------- | +|----------------|------------------------------------------| | `smartMeterId` | One of the smart meters' id listed above | Retrieving readings using CURL @@ -284,7 +284,7 @@ GET /price-plans/recommend/[?limit=] Parameters | Parameter | Description | -| -------------- | ---------------------------------------------------- | +|----------------|------------------------------------------------------| | `smartMeterId` | One of the smart meters' id listed above | | `limit` | (Optional) limit the number of plans to be displayed | diff --git a/build.gradle.kts b/build.gradle.kts index 75af7e7..c278cbe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,8 @@ + plugins { - application kotlin("jvm") + id("io.ktor.plugin") + id("io.gitlab.arturbosch.detekt") } val kotlin_version: String by project @@ -8,12 +10,10 @@ 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 { @@ -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 { @@ -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") + } } diff --git a/gradle.properties b/gradle.properties index bc49353..b416616 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts index f6eae3b..c94290a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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 } } diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt deleted file mode 100644 index bed0df2..0000000 --- a/src/main/kotlin/Application.kt +++ /dev/null @@ -1,100 +0,0 @@ -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.domain.Response -import de.tw.energy.domain.ResponseWithBody -import de.tw.energy.services.AccountService -import de.tw.energy.services.MeterReadingService -import de.tw.energy.services.PricePlanService -import io.ktor.application.Application -import io.ktor.application.ApplicationCall -import io.ktor.application.call -import io.ktor.application.install -import io.ktor.features.ContentNegotiation -import io.ktor.jackson.jackson -import io.ktor.request.receive -import io.ktor.response.respond -import io.ktor.routing.get -import io.ktor.routing.post -import io.ktor.routing.route -import io.ktor.routing.routing -import java.math.BigDecimal - -fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) - -suspend inline fun ApplicationCall.respond(r: Response) { - response.status(r.statusCode) - - if (r is ResponseWithBody) { - response.pipeline.execute(this, r.body!!) - } -} - -@Suppress("unused") // Referenced in application.conf -fun Application.module() { - install(ContentNegotiation) { - jackson { - setup() - } - } - - val meterReadingsService = MeterReadingService(mutableMapOf()) - val pricePlanService = 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 - ) - val accountService = 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 - ) - ) - - routing { - route("/readings") { - val controller = MeterReadingController(meterReadingsService) - - get("/read/{smartMeterId}") { - val smartMeterId = call.parameters["smartMeterId"] ?: "" - val response = controller.readings(smartMeterId) - call.respond(response.statusCode, response) - } - - post("/store") { - val readings = call.receive() - val response = controller.storeReadings(readings) - call.respond(response.statusCode, response) - } - } - - route("/price-plans") { - val controller = PricePlanComparatorController( - pricePlanService, - accountService - ) - - get("/compare-all/{smartMeterId}") { - val smartMeterId = call.parameters["smartMeterId"] ?: "" - val response = controller.calculatedCostForEachPricePlan(smartMeterId) - call.respond(response.statusCode, response) - } - - get("/recommend/{smartMeterId}") { - val smartMeterId = call.parameters["smartMeterId"] ?: "" - val limit = call.request.queryParameters["limit"] - val response = controller.recommendCheapestPricePlans(smartMeterId, limit?.toInt()) - call.respond(response.statusCode, response) - } - } - } -} diff --git a/src/main/kotlin/de/tw/energy/Application.kt b/src/main/kotlin/de/tw/energy/Application.kt new file mode 100644 index 0000000..a2a64f7 --- /dev/null +++ b/src/main/kotlin/de/tw/energy/Application.kt @@ -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): 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() + 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 +) diff --git a/src/main/kotlin/Fixtures.kt b/src/main/kotlin/de/tw/energy/Fixtures.kt similarity index 100% rename from src/main/kotlin/Fixtures.kt rename to src/main/kotlin/de/tw/energy/Fixtures.kt diff --git a/src/main/kotlin/ObjectMapper.kt b/src/main/kotlin/de/tw/energy/ObjectMapper.kt similarity index 81% rename from src/main/kotlin/ObjectMapper.kt rename to src/main/kotlin/de/tw/energy/ObjectMapper.kt index 284e0a3..72ab5a9 100644 --- a/src/main/kotlin/ObjectMapper.kt +++ b/src/main/kotlin/de/tw/energy/ObjectMapper.kt @@ -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 } diff --git a/src/main/kotlin/controllers/MeterReadingController.kt b/src/main/kotlin/de/tw/energy/controllers/MeterReadingController.kt similarity index 51% rename from src/main/kotlin/controllers/MeterReadingController.kt rename to src/main/kotlin/de/tw/energy/controllers/MeterReadingController.kt index 789d110..a5ff224 100644 --- a/src/main/kotlin/controllers/MeterReadingController.kt +++ b/src/main/kotlin/de/tw/energy/controllers/MeterReadingController.kt @@ -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> { - return readingService[smartMeterId]?.let { - Response.body(it) - } ?: Response.notFound() + fun readings(smartMeterId: String): List? { + return readingService[smartMeterId] } - fun storeReadings(readings: MeterReadings): Response { - 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() diff --git a/src/main/kotlin/controllers/PricePlanComparatorController.kt b/src/main/kotlin/de/tw/energy/controllers/PricePlanComparatorController.kt similarity index 71% rename from src/main/kotlin/controllers/PricePlanComparatorController.kt rename to src/main/kotlin/de/tw/energy/controllers/PricePlanComparatorController.kt index d69defa..2e412c4 100644 --- a/src/main/kotlin/controllers/PricePlanComparatorController.kt +++ b/src/main/kotlin/de/tw/energy/controllers/PricePlanComparatorController.kt @@ -1,6 +1,5 @@ package de.tw.energy.controllers -import de.tw.energy.domain.Response import de.tw.energy.services.AccountService import de.tw.energy.services.PricePlanService import java.math.BigDecimal @@ -14,36 +13,32 @@ class PricePlanComparatorController( val pricePlanComparisons: Map ) - fun calculatedCostForEachPricePlan(smartMeterId: String): Response { + fun calculatedCostForEachPricePlan(smartMeterId: String): CostsPerPlan? { val pricePlanId = accountService[smartMeterId] val consumptionsForPricePlans = pricePlanService.consumptionCostOfElectricityReadingsPerPricePlan(smartMeterId) return pricePlanId?.let { retrievedPricePlanId -> consumptionsForPricePlans?.let { consumptions -> - Response.body( - CostsPerPlan( - retrievedPricePlanId, - consumptions - ) + CostsPerPlan( + retrievedPricePlanId, + consumptions ) - } - } ?: Response.notFound() + } } fun recommendCheapestPricePlans( smartMeterId: String, limit: Int? = null - ): Response>> { + ): List>? { val consumptionsForPricePlans = pricePlanService.consumptionCostOfElectricityReadingsPerPricePlan(smartMeterId) return consumptionsForPricePlans?.let { consumptions -> val adjustedLimit = if (limit == null || limit > consumptions.size) consumptions.size else limit - val recommendations = consumptions + return consumptions .toList() .sortedBy { it.second } .subList(0, adjustedLimit) - return Response.body(recommendations) - } ?: Response.notFound() + } } } diff --git a/src/main/kotlin/domain/ElectricityReading.kt b/src/main/kotlin/de/tw/energy/domain/ElectricityReading.kt similarity index 100% rename from src/main/kotlin/domain/ElectricityReading.kt rename to src/main/kotlin/de/tw/energy/domain/ElectricityReading.kt diff --git a/src/main/kotlin/domain/MeterReadings.kt b/src/main/kotlin/de/tw/energy/domain/MeterReadings.kt similarity index 100% rename from src/main/kotlin/domain/MeterReadings.kt rename to src/main/kotlin/de/tw/energy/domain/MeterReadings.kt diff --git a/src/main/kotlin/domain/PricePlan.kt b/src/main/kotlin/de/tw/energy/domain/PricePlan.kt similarity index 100% rename from src/main/kotlin/domain/PricePlan.kt rename to src/main/kotlin/de/tw/energy/domain/PricePlan.kt diff --git a/src/main/kotlin/generator/ElectricityReadingsGenerator.kt b/src/main/kotlin/de/tw/energy/generator/ElectricityReadingsGenerator.kt similarity index 56% rename from src/main/kotlin/generator/ElectricityReadingsGenerator.kt rename to src/main/kotlin/de/tw/energy/generator/ElectricityReadingsGenerator.kt index e01a151..cdd9e53 100644 --- a/src/main/kotlin/generator/ElectricityReadingsGenerator.kt +++ b/src/main/kotlin/de/tw/energy/generator/ElectricityReadingsGenerator.kt @@ -3,20 +3,17 @@ package de.tw.energy.generator import de.tw.energy.domain.ElectricityReading import java.math.BigDecimal import java.time.Instant -import java.util.* -import kotlin.math.abs +import kotlin.random.Random + +const val DESIRED_INTERVAL = 10L fun generateElectricityReadings(number: Int): List { - val random = Random() val now = Instant.now() return (0 until number).map { - val reading = abs(random.nextGaussian()) ElectricityReading( - now.minusSeconds((it * 10).toLong()), - BigDecimal.valueOf(reading) + now.minusSeconds((it * DESIRED_INTERVAL)), + BigDecimal.valueOf(Random.nextDouble()) ) - - }.sortedBy { it.time } + } } - diff --git a/src/main/kotlin/de/tw/energy/ktor/BodyAdapterPlugin.kt b/src/main/kotlin/de/tw/energy/ktor/BodyAdapterPlugin.kt new file mode 100644 index 0000000..458d281 --- /dev/null +++ b/src/main/kotlin/de/tw/energy/ktor/BodyAdapterPlugin.kt @@ -0,0 +1,38 @@ +package de.tw.energy.ktor +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.NullBody +import io.ktor.server.application.createApplicationPlugin + +val BodyAdapterPlugin = createApplicationPlugin(name = "BodyAdapterPlugin") { + + onCallRespond { call -> + transformBody { data -> + when (data) { + is HttpStatusCode -> { + call.response.status(data) + HttpStatusCodeWrapper(data) + } + + is NullBody -> { + call.response.status(HttpStatusCode.NotFound) + HttpStatusCodeWrapper(HttpStatusCode.NotFound) + } + + is IllegalArgumentException -> { + call.response.status(HttpStatusCode.BadRequest) + HttpStatusCodeWithErrorMessage(HttpStatusCode.BadRequest, data.message ?: "Invalid data") + } + + else -> { + HttpStatusCodeWithBodyWrapper(call.response.status() ?: HttpStatusCode.OK, data) + } + } + } + } +} + +data class HttpStatusCodeWithErrorMessage(val statusCode: HttpStatusCode, val errorMessage: String) + +data class HttpStatusCodeWrapper(val statusCode: HttpStatusCode) + +data class HttpStatusCodeWithBodyWrapper(val statusCode: HttpStatusCode, val body: T) diff --git a/src/main/kotlin/services/AccountService.kt b/src/main/kotlin/de/tw/energy/services/AccountService.kt similarity index 100% rename from src/main/kotlin/services/AccountService.kt rename to src/main/kotlin/de/tw/energy/services/AccountService.kt diff --git a/src/main/kotlin/services/MeterReadingService.kt b/src/main/kotlin/de/tw/energy/services/MeterReadingService.kt similarity index 100% rename from src/main/kotlin/services/MeterReadingService.kt rename to src/main/kotlin/de/tw/energy/services/MeterReadingService.kt diff --git a/src/main/kotlin/services/PricePlanService.kt b/src/main/kotlin/de/tw/energy/services/PricePlanService.kt similarity index 87% rename from src/main/kotlin/services/PricePlanService.kt rename to src/main/kotlin/de/tw/energy/services/PricePlanService.kt index d009b6c..bc01a31 100644 --- a/src/main/kotlin/services/PricePlanService.kt +++ b/src/main/kotlin/de/tw/energy/services/PricePlanService.kt @@ -6,14 +6,17 @@ import java.math.BigDecimal import java.math.RoundingMode import java.time.Duration +private const val SECONDS_IN_AN_HOUR = 3600.0 + class PricePlanService(private val pricePlans: List, private val meterReadingService: MeterReadingService) { + fun consumptionCostOfElectricityReadingsPerPricePlan(smartMeterId: String): Map? { val readings = meterReadingService[smartMeterId] return readings?.let { - pricePlans.map { pricePlan -> + pricePlans.associate { pricePlan -> pricePlan.planName to calculateCost(readings, pricePlan) - }.toMap() + } } } @@ -28,7 +31,7 @@ class PricePlanService(private val pricePlans: List, private val mete private fun calculateAverageReading(readings: List): BigDecimal { val summedReadings = readings .map { it.reading } - .fold(BigDecimal.ZERO, { reading, acc -> reading.add(acc) }) + .fold(BigDecimal.ZERO) { reading, acc -> reading.add(acc) } return summedReadings.divide(BigDecimal.valueOf(readings.size.toLong()), RoundingMode.HALF_UP) } @@ -39,8 +42,7 @@ class PricePlanService(private val pricePlans: List, private val mete return BigDecimal.valueOf( Duration.between(first.time, last.time) - .seconds / 3600.0 + .seconds / SECONDS_IN_AN_HOUR ) } - } diff --git a/src/main/kotlin/domain/Response.kt b/src/main/kotlin/domain/Response.kt deleted file mode 100644 index 1b69ff8..0000000 --- a/src/main/kotlin/domain/Response.kt +++ /dev/null @@ -1,30 +0,0 @@ -package de.tw.energy.domain - -import io.ktor.http.HttpStatusCode - -sealed class Response() { - companion object { - fun empty() = EmptyResponse - fun notFound() = NotFoundResponse - fun internalError() = InternalErrorResponse - fun body(body: T) = ResponseWithBody(HttpStatusCode.OK, body) - } - - abstract val statusCode: HttpStatusCode - - override fun toString() = "Response[status=$statusCode]" -} - -object NotFoundResponse : Response() { - override val statusCode = HttpStatusCode.NotFound -} - -object InternalErrorResponse : Response() { - override val statusCode = HttpStatusCode.InternalServerError -} - -object EmptyResponse : Response() { - override val statusCode = HttpStatusCode.OK -} - -data class ResponseWithBody(override val statusCode: HttpStatusCode, val body: T) : Response() diff --git a/src/test/kotlin/ApplicationTest.kt b/src/test/kotlin/ApplicationTest.kt deleted file mode 100644 index ee0dcb5..0000000 --- a/src/test/kotlin/ApplicationTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package de.tw.energy - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import de.tw.energy.domain.MeterReadings -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.server.testing.TestApplicationEngine -import io.ktor.server.testing.handleRequest -import io.ktor.server.testing.setBody -import io.ktor.server.testing.withTestApplication -import strikt.api.expectThat -import strikt.assertions.isEqualTo -import kotlin.test.Test - -class ApplicationTest { - private fun Any.toJson() = jacksonObjectMapper() - .setup() - .writeValueAsString(this) - - @Test - fun `stores readings`() { - withTestApplication({ module() }) { - handleRequest(HttpMethod.Post, "/readings/store") { - addHeader(HttpHeaders.Accept, "application/json") - addHeader(HttpHeaders.ContentType, "application/json") - setBody(MeterReadings.generate("smart-meter-1").toJson()) - - }.apply { - expectThat(response) - .get { status() }.isEqualTo(HttpStatusCode.OK) - } - } - } - - @Test - fun `retrieves readings`() { - withTestApplication({ module() }) { - populateReadings() - - handleRequest(HttpMethod.Get, "/readings/read/smart-meter-1") { - addHeader(HttpHeaders.Accept, "application/json") - - }.apply { - expectThat(response) - .get { status() }.isEqualTo(HttpStatusCode.OK) - } - } - } - - @Test - fun `compares prices`() { - withTestApplication({ module() }) { - populateReadings() - - handleRequest(HttpMethod.Get, "/price-plans/compare-all/smart-meter-1") { - addHeader(HttpHeaders.Accept, "application/json") - }.apply { - expectThat(response) - .get { status() }.isEqualTo(HttpStatusCode.OK) - } - } - } - - @Test - fun `recommends a price plan`() { - withTestApplication({ module() }) { - populateReadings() - - handleRequest(HttpMethod.Get, "/price-plans/recommend/smart-meter-1?limit=2") { - addHeader(HttpHeaders.Accept, "application/json") - }.apply { - expectThat(response) - .get { status() }.isEqualTo(HttpStatusCode.OK) - } - } - } - - private fun TestApplicationEngine.populateReadings() { - handleRequest(HttpMethod.Post, "/readings/store") { - addHeader(HttpHeaders.Accept, "application/json") - addHeader(HttpHeaders.ContentType, "application/json") - setBody(MeterReadings.generate("smart-meter-1").toJson()) - - } - } -} diff --git a/src/test/kotlin/de/tw/energy/ApplicationTest.kt b/src/test/kotlin/de/tw/energy/ApplicationTest.kt new file mode 100644 index 0000000..69db536 --- /dev/null +++ b/src/test/kotlin/de/tw/energy/ApplicationTest.kt @@ -0,0 +1,154 @@ +package de.tw.energy + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import de.tw.energy.controllers.INVALID_READINGS_MESSAGE +import de.tw.energy.domain.ElectricityReading +import de.tw.energy.domain.MeterReadings +import de.tw.energy.ktor.HttpStatusCodeWithBodyWrapper +import de.tw.energy.ktor.HttpStatusCodeWithErrorMessage +import de.tw.energy.ktor.HttpStatusCodeWrapper +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.jackson.jackson +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import strikt.api.expectThat +import strikt.assertions.isEqualTo +import kotlin.test.Test + +class ApplicationTest { + private fun Any.toJson() = jacksonObjectMapper() + .setup() + .writeValueAsString(this) + + @Test + fun `stores readings`() { + testApplication { + val client = jacksonAwareClient() + val response = client.post("/readings/store") { + header(HttpHeaders.Accept, ContentType.Application.Json) + header(HttpHeaders.ContentType, ContentType.Application.Json) + setBody(MeterReadings.generate("smart-meter-1")) + } + expectThat(response).get { status }.isEqualTo(HttpStatusCode.OK) + expectThat(response.bodyAsText()).isEqualTo(HttpStatusCodeWrapper(HttpStatusCode.OK).toJson()) + } + } + + @Test + fun `sends 400 if asked to store readings with empty meter id`() { + testApplication { + val client = jacksonAwareClient() + val response = client.post("/readings/store") { + header(HttpHeaders.Accept, ContentType.Application.Json) + header(HttpHeaders.ContentType, ContentType.Application.Json) + setBody(MeterReadings.generate("")) + } + expectThat(response).get { status }.isEqualTo(HttpStatusCode.BadRequest) + expectThat(response.bodyAsText()).isEqualTo( + HttpStatusCodeWithErrorMessage(HttpStatusCode.BadRequest, INVALID_READINGS_MESSAGE).toJson() + ) + } + } + + @Test + fun `retrieves readings`() { + testApplication { + val client = jacksonAwareClient() + populateReadings(client) + val response = client.get("/readings/read/smart-meter-1") { + header(HttpHeaders.Accept, ContentType.Application.Json) + } + expectThat(response).get { status }.isEqualTo(HttpStatusCode.OK) + val responseBody: HttpStatusCodeWithBodyWrapper> = response.body() + expectThat(responseBody.statusCode).isEqualTo(HttpStatusCode.OK) + } + } + + @Test + fun `sends 404 when retrieving readings from non-existent mete`() { + testApplication { + val client = jacksonAwareClient() + populateReadings(client) + val response = client.get("/readings/read/does-not-exist") { + header(HttpHeaders.Accept, ContentType.Application.Json) + } + expectThat(response).get { status }.isEqualTo(HttpStatusCode.NotFound) + expectThat(response.bodyAsText()).isEqualTo(HttpStatusCodeWrapper(HttpStatusCode.NotFound).toJson()) + } + } + + @Test + fun `compares prices`() { + testApplication { + val client = jacksonAwareClient() + populateReadings(client) + val response = client.get("/price-plans/compare-all/smart-meter-1") { + header(HttpHeaders.Accept, ContentType.Application.Json) + } + expectThat(response).get { status }.isEqualTo(HttpStatusCode.OK) + } + } + + @Test + fun `sends 404 when asked to compare prices for non-existent meter`() { + testApplication { + val client = jacksonAwareClient() + populateReadings(client) + val response = client.get("/price-plans/compare-all/does-not-exist") { + header(HttpHeaders.Accept, ContentType.Application.Json) + } + expectThat(response).get { status }.isEqualTo(HttpStatusCode.NotFound) + expectThat(response.bodyAsText()).isEqualTo(HttpStatusCodeWrapper(HttpStatusCode.NotFound).toJson()) + } + } + + @Test + fun `recommends a price plan`() { + testApplication { + val client = jacksonAwareClient() + populateReadings(client) + val response = client.get("/price-plans/recommend/smart-meter-1?limit=2") { + header(HttpHeaders.Accept, ContentType.Application.Json) + } + expectThat(response).get { status }.isEqualTo(HttpStatusCode.OK) + } + } + + @Test + fun `sends 404 when asked to recommend a price plan for a non-existent meter`() { + testApplication { + val client = jacksonAwareClient() + populateReadings(client) + val response = client.get("/price-plans/recommend/does-not-exist?limit=2") { + header(HttpHeaders.Accept, ContentType.Application.Json) + } + expectThat(response).get { status }.isEqualTo(HttpStatusCode.NotFound) + expectThat(response.bodyAsText()).isEqualTo(HttpStatusCodeWrapper(HttpStatusCode.NotFound).toJson()) + } + } + + private suspend fun populateReadings(client: HttpClient) { + client.post("/readings/store") { + header(HttpHeaders.Accept, ContentType.Application.Json) + header(HttpHeaders.ContentType, ContentType.Application.Json) + setBody(MeterReadings.generate("smart-meter-1")) + } + } + private fun ApplicationTestBuilder.jacksonAwareClient() = createClient { + install(ContentNegotiation) { + jackson { + setup() + } + } + } +} diff --git a/src/test/kotlin/controllers/MeterReadingControllerTest.kt b/src/test/kotlin/de/tw/energy/controllers/MeterReadingControllerTest.kt similarity index 71% rename from src/test/kotlin/controllers/MeterReadingControllerTest.kt rename to src/test/kotlin/de/tw/energy/controllers/MeterReadingControllerTest.kt index 553a673..d3a2c51 100644 --- a/src/test/kotlin/controllers/MeterReadingControllerTest.kt +++ b/src/test/kotlin/de/tw/energy/controllers/MeterReadingControllerTest.kt @@ -1,23 +1,25 @@ package de.tw.energy.controllers -import de.tw.energy.domain.* +import de.tw.energy.domain.MeterReadings import de.tw.energy.services.MeterReadingService +import strikt.api.expectCatching import strikt.api.expectThat import strikt.assertions.isA import strikt.assertions.isEqualTo +import strikt.assertions.isFailure import kotlin.test.Test +private const val SMART_METER_ID = "10101010" +private const val OTHER_SMART_METER_ID = "20202020" + class MeterReadingControllerTest { - val SMART_METER_ID = "10101010" - val OTHER_SMART_METER_ID = "20202020" - val meterReadingService = MeterReadingService(mutableMapOf()) - val controller = MeterReadingController(meterReadingService) + private val meterReadingService = MeterReadingService(mutableMapOf()) + private val controller = MeterReadingController(meterReadingService) @Test fun `returns not found if the meter id is not found`() { expectThat(controller.readings(SMART_METER_ID)) - .isA() } @Test @@ -29,21 +31,21 @@ class MeterReadingControllerTest { meterReadingService.store(otherReadings.smartMeterId, otherReadings.readings) expectThat(controller.readings(SMART_METER_ID)) - .isA>>() - .get { body }.isEqualTo(readings.readings) - + .isEqualTo(readings.readings) } @Test fun `returns error if storing readings for an empty meter`() { - expectThat(controller.storeReadings(MeterReadings.generate(""))) - .isA() + expectCatching { controller.storeReadings(MeterReadings.generate("")) } + .isFailure() + .isA() } @Test fun `returns error if storing empty list of readings`() { - expectThat(controller.storeReadings(MeterReadings(SMART_METER_ID, listOf()))) - .isA() + expectCatching { controller.storeReadings(MeterReadings(SMART_METER_ID, listOf())) } + .isFailure() + .isA() } @Test @@ -67,5 +69,4 @@ class MeterReadingControllerTest { expectThat(meterReadingService[SMART_METER_ID]).isEqualTo(readings.readings) } - } diff --git a/src/test/kotlin/controllers/PricePlanComparatorControllerTest.kt b/src/test/kotlin/de/tw/energy/controllers/PricePlanComparatorControllerTest.kt similarity index 79% rename from src/test/kotlin/controllers/PricePlanComparatorControllerTest.kt rename to src/test/kotlin/de/tw/energy/controllers/PricePlanComparatorControllerTest.kt index 7cb516a..40724f3 100644 --- a/src/test/kotlin/controllers/PricePlanComparatorControllerTest.kt +++ b/src/test/kotlin/de/tw/energy/controllers/PricePlanComparatorControllerTest.kt @@ -1,30 +1,27 @@ package de.tw.energy.controllers import de.tw.energy.domain.ElectricityReading -import de.tw.energy.domain.NotFoundResponse import de.tw.energy.domain.PricePlan -import de.tw.energy.domain.ResponseWithBody import de.tw.energy.services.AccountService import de.tw.energy.services.MeterReadingService import de.tw.energy.services.PricePlanService import strikt.api.expectThat -import strikt.assertions.isA import strikt.assertions.isEqualTo +import strikt.assertions.isNull import java.math.BigDecimal import java.time.Instant import kotlin.test.Test -class PricePlanComparatorControllerTest { - - val PRICE_PLAN_1_ID = "test-supplier" - val PRICE_PLAN_2_ID = "best-supplier" - val PRICE_PLAN_3_ID = "second-best-supplier" - val SMART_METER_ID = "smart-meter-id" +private const val PRICE_PLAN_1_ID = "test-supplier" +private const val PRICE_PLAN_2_ID = "best-supplier" +private const val PRICE_PLAN_3_ID = "second-best-supplier" +private const val SMART_METER_ID = "smart-meter-id" +private const val ENERGY_SUPPLIER_NAME = "Energy Supplier Name" - val ENERGY_SUPPLIER_NAME = "Energy Supplier Name" +class PricePlanComparatorControllerTest { - val meterReadingService = MeterReadingService(mutableMapOf()) - val pricePlanService = PricePlanService( + private val meterReadingService = MeterReadingService(mutableMapOf()) + private val pricePlanService = PricePlanService( listOf( PricePlan(PRICE_PLAN_1_ID, ENERGY_SUPPLIER_NAME, BigDecimal.TEN, listOf()), PricePlan(PRICE_PLAN_2_ID, ENERGY_SUPPLIER_NAME, BigDecimal.ONE, listOf()), @@ -43,7 +40,7 @@ class PricePlanComparatorControllerTest { @Test fun `returns not found when calculating costs for a non matching meter id`() { expectThat(controller.calculatedCostForEachPricePlan("not-found")) - .isA() + .isNull() } @Test @@ -64,8 +61,7 @@ class PricePlanComparatorControllerTest { ) expectThat(controller.calculatedCostForEachPricePlan(SMART_METER_ID)) - .isA>() - .get { body }.isEqualTo(expected) + .isEqualTo(expected) } @Test @@ -81,8 +77,7 @@ class PricePlanComparatorControllerTest { ) expectThat(controller.recommendCheapestPricePlans(SMART_METER_ID)) - .isA>>>() - .get { body }.isEqualTo(expectedPricePlanToCost) + .isEqualTo(expectedPricePlanToCost) } @Test @@ -97,8 +92,7 @@ class PricePlanComparatorControllerTest { ) expectThat(controller.recommendCheapestPricePlans(SMART_METER_ID, 2)) - .isA>>>() - .get { body }.isEqualTo(expectedPricePlanToCost) + .isEqualTo(expectedPricePlanToCost) } @Test @@ -114,7 +108,6 @@ class PricePlanComparatorControllerTest { ) expectThat(controller.recommendCheapestPricePlans(SMART_METER_ID, 5)) - .isA>>>() - .get { body }.isEqualTo(expectedPricePlanToCost) + .isEqualTo(expectedPricePlanToCost) } } diff --git a/src/test/kotlin/domain/PricePlanTest.kt b/src/test/kotlin/de/tw/energy/domain/PricePlanTest.kt similarity index 91% rename from src/test/kotlin/domain/PricePlanTest.kt rename to src/test/kotlin/de/tw/energy/domain/PricePlanTest.kt index 1eb51d3..780cc61 100644 --- a/src/test/kotlin/domain/PricePlanTest.kt +++ b/src/test/kotlin/de/tw/energy/domain/PricePlanTest.kt @@ -13,10 +13,11 @@ fun Assertion.Builder.isCloseTo(target: BigDecimal) = it.minus(target).abs() < BigDecimal.valueOf(0.1) } +private const val ENERGY_SUPPLIER_NAME = "Energy Supplier Name" + class PricePlanTest { - val ENERGY_SUPPLIER_NAME = "Energy Supplier Name" - val peakTimeMultiplier = PricePlan.PeakTimeMultiplier(DayOfWeek.WEDNESDAY, BigDecimal.TEN) + private val peakTimeMultiplier = PricePlan.PeakTimeMultiplier(DayOfWeek.WEDNESDAY, BigDecimal.TEN) @Test fun `returns the base price given an ordinary datetime`() { diff --git a/src/test/kotlin/services/AccountServiceTest.kt b/src/test/kotlin/de/tw/energy/services/AccountServiceTest.kt similarity index 61% rename from src/test/kotlin/services/AccountServiceTest.kt rename to src/test/kotlin/de/tw/energy/services/AccountServiceTest.kt index aec6bd9..5e25352 100644 --- a/src/test/kotlin/services/AccountServiceTest.kt +++ b/src/test/kotlin/de/tw/energy/services/AccountServiceTest.kt @@ -4,11 +4,12 @@ import strikt.api.expectThat import strikt.assertions.isEqualTo import kotlin.test.Test +private const val PRICE_PLAN_ID = "price-plan-id" +private const val SMART_METER_ID = "smartmeter-id-1" + class AccountServiceTest { - val PRICE_PLAN_ID = "price-plan-id" - val SMART_METER_ID = "smartmeter-id-1" - val service = AccountService(mapOf(SMART_METER_ID to PRICE_PLAN_ID)) + private val service = AccountService(mapOf(SMART_METER_ID to PRICE_PLAN_ID)) @Test fun `returns the price plan id based on the smart meter`() { diff --git a/src/test/kotlin/services/MeterReadingServiceTest.kt b/src/test/kotlin/de/tw/energy/services/MeterReadingServiceTest.kt similarity index 100% rename from src/test/kotlin/services/MeterReadingServiceTest.kt rename to src/test/kotlin/de/tw/energy/services/MeterReadingServiceTest.kt