diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1182c84..8f50f01 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,24 +1,33 @@ +import java.net.URI + plugins { val kotlinVersion = "1.9.23" kotlin("jvm") version kotlinVersion kotlin("plugin.spring") version kotlinVersion id("org.springframework.boot") version "3.2.3" + id("com.vaadin") version "24.3.7" } group = "hu.kotlin.feladat.ms" repositories { mavenCentral() + maven { url = URI("https://maven.vaadin.com/vaadin-addons") } } dependencies { implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)) implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.vaadin:vaadin-core:24.+") + implementation("com.vaadin:vaadin-spring-boot-starter:24.+") testImplementation(kotlin("test")) - testImplementation("io.mockk:mockk:1.4.1") + testImplementation("io.mockk:mockk:1.9.3") + testImplementation("org.springframework.boot:spring-boot-starter-test") } +defaultTasks("clean", "vaadinBuildFrontend", "build") + tasks.test { useJUnitPlatform() } @@ -26,3 +35,7 @@ tasks.test { kotlin { jvmToolchain(17) } + +vaadin { + optimizeBundle = false +} \ No newline at end of file diff --git a/app/src/main/kotlin/WeatherApp.kt b/app/src/main/kotlin/WeatherApp.kt index e0c098a..aa04459 100644 --- a/app/src/main/kotlin/WeatherApp.kt +++ b/app/src/main/kotlin/WeatherApp.kt @@ -1,10 +1,15 @@ package hu.vanio.kotlin.feladat.ms +import com.vaadin.flow.component.page.AppShellConfigurator +import com.vaadin.flow.theme.Theme import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication @SpringBootApplication -class WeatherApp +@EnableConfigurationProperties +@Theme +class WeatherApp: AppShellConfigurator fun main() { runApplication() diff --git a/app/src/main/kotlin/WeatherAppConfig.kt b/app/src/main/kotlin/WeatherAppConfig.kt new file mode 100644 index 0000000..845e47d --- /dev/null +++ b/app/src/main/kotlin/WeatherAppConfig.kt @@ -0,0 +1,8 @@ +package hu.vanio.kotlin.feladat.ms + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties(prefix = "weather") +class WeatherAppConfig(var appUrl: String = "") diff --git a/app/src/main/kotlin/WeatherCommandLineRunner.kt b/app/src/main/kotlin/WeatherCommandLineRunner.kt new file mode 100644 index 0000000..c5dffa4 --- /dev/null +++ b/app/src/main/kotlin/WeatherCommandLineRunner.kt @@ -0,0 +1,29 @@ +package hu.vanio.kotlin.feladat.ms + +import hu.vanio.kotlin.feladat.ms.exception.ServiceUnavailable +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherForecasts +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherService +import org.springframework.boot.CommandLineRunner +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component + + +const val separator = "----------------------------------------------" + +@Component +@Order(1) +class WeatherCommandLineRunner(val weatherService: WeatherService): CommandLineRunner { + + override fun run(vararg args: String?) { + val weatherStatistics = try { weatherService.getWeatherStatistics() } catch (e: ServiceUnavailable) { + println("Weather forecast service is unavailable") + return + } + println("$separator\n${weatherStatistics.println()}$separator") + } +} + +fun WeatherForecasts.println() = + "Weather forecast daily averages:\n$separator\n${this.weatherDailyForecast + .map { wdf -> "${wdf.day} | ${wdf.average()}"} + .reduce { first, second -> "${first}\n${second}"}}\n" \ No newline at end of file diff --git a/app/src/main/kotlin/exception/ServiceUnavailable.kt b/app/src/main/kotlin/exception/ServiceUnavailable.kt new file mode 100644 index 0000000..ba5ec5b --- /dev/null +++ b/app/src/main/kotlin/exception/ServiceUnavailable.kt @@ -0,0 +1,6 @@ +package hu.vanio.kotlin.feladat.ms.exception + +class ServiceUnavailable(private val serviceId: String): RuntimeException() { + override val message: String + get() = "Service $serviceId is unavailable" +} \ No newline at end of file diff --git a/app/src/main/kotlin/openmeteo/WeatherForecast.kt b/app/src/main/kotlin/openmeteo/WeatherForecast.kt new file mode 100644 index 0000000..90584fd --- /dev/null +++ b/app/src/main/kotlin/openmeteo/WeatherForecast.kt @@ -0,0 +1,31 @@ +package hu.vanio.kotlin.feladat.ms.openmeteo + +import com.fasterxml.jackson.annotation.JsonProperty + +data class WeatherForecast ( + val latitude: Double, + val longitude: Double, + @JsonProperty("generationtime_ms") + val generationtimeMs: Double, + @JsonProperty("utc_offset_seconds") + val utcOffsetSeconds: Long, + val timezone: String, + @JsonProperty("timezone_abbreviation") + val timezoneAbbreviation: String, + val elevation: Double, + @JsonProperty("hourly_units") + val hourlyUnits: HourlyUnits, + val hourly: Hourly, +) + +data class HourlyUnits( + val time: String, + @JsonProperty("temperature_2m") + val temperature2m: String, +) + +data class Hourly( + val time: List, + @JsonProperty("temperature_2m") + val temperature2m: List, +) \ No newline at end of file diff --git a/app/src/main/kotlin/openmeteo/WeatherForecasts.kt b/app/src/main/kotlin/openmeteo/WeatherForecasts.kt new file mode 100644 index 0000000..7e8526b --- /dev/null +++ b/app/src/main/kotlin/openmeteo/WeatherForecasts.kt @@ -0,0 +1,18 @@ +package hu.vanio.kotlin.feladat.ms.openmeteo + +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDate + +data class WeatherDailyForecast(val day: LocalDate, private val temperatures: List) { + fun average(): Double = temperatures.average().round(2) + + override fun toString(): String { + return "$day - ${average()}" + } +} + +fun Double.round(digits: Int) = + BigDecimal(this).setScale(digits, RoundingMode.HALF_EVEN).toDouble() + +data class WeatherForecasts(val weatherDailyForecast: List) \ No newline at end of file diff --git a/app/src/main/kotlin/openmeteo/WeatherService.kt b/app/src/main/kotlin/openmeteo/WeatherService.kt new file mode 100644 index 0000000..569dae1 --- /dev/null +++ b/app/src/main/kotlin/openmeteo/WeatherService.kt @@ -0,0 +1,51 @@ +package hu.vanio.kotlin.feladat.ms.openmeteo + +import com.fasterxml.jackson.databind.ObjectMapper +import hu.vanio.kotlin.feladat.ms.WeatherAppConfig +import hu.vanio.kotlin.feladat.ms.exception.ServiceUnavailable +import org.springframework.stereotype.Service +import java.io.IOException + +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Service +class WeatherService(var config: WeatherAppConfig, var objectMapper: ObjectMapper) { + private val id = "WEATHER_SERVICE" + + fun getWeatherStatistics(): WeatherForecasts { + val request = HttpRequest.newBuilder() + .uri(URI(config.appUrl)) + .GET() + .build() + val weatherForecast = try {objectMapper.readValue(HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()).body(), WeatherForecast::class.java) + } catch (e: IOException) { + throw ServiceUnavailable(id) + } + return statistics(weatherForecast) + } + + companion object { + fun statistics(weatherForecast: WeatherForecast): WeatherForecasts { + val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm") + val weatherMap = mutableMapOf>() + var i = 0 + + weatherForecast.hourly.time.forEach { + val temperature = weatherForecast.hourly.temperature2m[i++] + val key = LocalDate.parse(it, dateTimeFormatter) + weatherMap.compute(key) { _, value -> + value?.plus(temperature)?.toMutableList() ?: mutableListOf(temperature) + } + } + return WeatherForecasts(weatherMap.map { + WeatherDailyForecast(it.key, it.value) + }.toList()) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/vaadin/CustomErrorHandler.kt b/app/src/main/kotlin/vaadin/CustomErrorHandler.kt new file mode 100644 index 0000000..3b3f3d8 --- /dev/null +++ b/app/src/main/kotlin/vaadin/CustomErrorHandler.kt @@ -0,0 +1,18 @@ +package hu.vanio.kotlin.feladat.ms.vaadin + +import com.vaadin.flow.component.UI +import com.vaadin.flow.component.notification.Notification +import com.vaadin.flow.server.ErrorEvent +import com.vaadin.flow.server.ErrorHandler +import org.springframework.stereotype.Component + +@Component +class CustomErrorHandler : ErrorHandler { + override fun error(errorEvent: ErrorEvent) { + if (UI.getCurrent() != null) { + UI.getCurrent().access { + Notification.show("An internal error has occurred. (${errorEvent.throwable.message})") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/vaadin/MainView.kt b/app/src/main/kotlin/vaadin/MainView.kt new file mode 100644 index 0000000..f5361e2 --- /dev/null +++ b/app/src/main/kotlin/vaadin/MainView.kt @@ -0,0 +1,38 @@ +package hu.vanio.kotlin.feladat.ms.vaadin + +import com.vaadin.flow.component.Key +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.grid.Grid +import com.vaadin.flow.component.notification.Notification +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.router.PageTitle +import com.vaadin.flow.router.Route +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherDailyForecast +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherService + +@PageTitle("Weather forecast") +@Route +class MainView(private val weatherService: WeatherService) : HorizontalLayout() { + private val table = Grid() + private val refresh = Button("Refresh").also { + it.addClickListener { _ -> + refreshTableContent() + Notification.show("Table content refreshed") } + it.addClickShortcut(Key.ENTER) + } + + private fun refreshTableContent() { + val weatherStatistics = weatherService.getWeatherStatistics() + table.setItems(weatherStatistics.weatherDailyForecast) + } + + init { + table.addColumn(WeatherDailyForecast::day).setHeader("Day") + table.addColumn(WeatherDailyForecast::average).setHeader("Daily average temperature") + + super.setMargin(true) + super.setVerticalComponentAlignment(FlexComponent.Alignment.END, table, refresh) + super.add(table, refresh) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/vaadin/VaadinInitListener.kt b/app/src/main/kotlin/vaadin/VaadinInitListener.kt new file mode 100644 index 0000000..d51f747 --- /dev/null +++ b/app/src/main/kotlin/vaadin/VaadinInitListener.kt @@ -0,0 +1,13 @@ +package hu.vanio.kotlin.feladat.ms.vaadin + +import com.vaadin.flow.server.ServiceInitEvent +import com.vaadin.flow.server.SessionInitEvent +import com.vaadin.flow.server.VaadinServiceInitListener +import org.springframework.stereotype.Component + +@Component +class VaadinInitListener(val customErrorHandler: CustomErrorHandler) : VaadinServiceInitListener { + override fun serviceInit(serviceEvent: ServiceInitEvent) { + serviceEvent.source.addSessionInitListener { initEvent: SessionInitEvent? -> initEvent?.session?.errorHandler = customErrorHandler} + } +} \ No newline at end of file diff --git a/app/src/main/resources/application.properties b/app/src/main/resources/application.properties new file mode 100644 index 0000000..5517427 --- /dev/null +++ b/app/src/main/resources/application.properties @@ -0,0 +1,12 @@ +weather.app-url=https://api.open-meteo.com/v1/forecast?latitude=47.4984&longitude=19.0404&hourly=temperature_2m&timezone=auto + +server.port=${PORT:8080} + +spring.mustache.check-template-location = false + +# Launch the default browser when starting the application in development mode +vaadin.launch-browser=true +# To improve the performance during development. +# For more information https://vaadin.com/docs/latest/integrations/spring/configuration#special-configuration-parameters +vaadin.allowed-packages = com.vaadin,org.vaadin,dev.hilla,com.example.application +spring.jpa.defer-datasource-initialization = true diff --git a/app/src/test/kotlin/WeatherAppTest.kt b/app/src/test/kotlin/WeatherAppTest.kt deleted file mode 100644 index a81a55a..0000000 --- a/app/src/test/kotlin/WeatherAppTest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package hu.vanio.kotlin.feladat.ms - -import kotlin.test.Test - -class WeatherAppTest { - - @Test fun `sikeres lekerdezes`() { - TODO() - } - -} \ No newline at end of file diff --git a/app/src/test/kotlin/WeatherCommandLineRunnerTest.kt b/app/src/test/kotlin/WeatherCommandLineRunnerTest.kt new file mode 100644 index 0000000..4e5a6d9 --- /dev/null +++ b/app/src/test/kotlin/WeatherCommandLineRunnerTest.kt @@ -0,0 +1,61 @@ + +import hu.vanio.kotlin.feladat.ms.WeatherCommandLineRunner +import hu.vanio.kotlin.feladat.ms.exception.ServiceUnavailable +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherDailyForecast +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherForecasts +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherService +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.time.LocalDate +import java.time.Month + +class WeatherCommandLineRunnerTest { + @MockK + lateinit var weatherService: WeatherService + + @BeforeEach + fun setUp() = MockKAnnotations.init(this) + + @Test + fun testConsoleLog() { + every { weatherService.getWeatherStatistics() } returns WeatherForecasts(listOf( + WeatherDailyForecast(LocalDate.of(2024, Month.MARCH, 1), listOf(1.0, 2.0, 3.0)))) + val weatherCommandLineRunner = WeatherCommandLineRunner(weatherService) + + assertStandardOutput("----------------------------------------------\n" + + "Weather forecast daily averages:\n" + + "----------------------------------------------\n" + + "2024-03-01 | 2.0\n" + + "----------------------------------------------") { weatherCommandLineRunner.run() } + } + + @Test + fun testServiceUnavailable() { + every { weatherService.getWeatherStatistics() } throws ServiceUnavailable("") + val weatherCommandLineRunner = WeatherCommandLineRunner(weatherService) + + assertStandardOutput("Weather forecast service is unavailable") { weatherCommandLineRunner.run() } + } + + private fun assertStandardOutput(expectedStdOut: String, functionCall: Runnable) { + val outputStream = ByteArrayOutputStream() + val printStream = PrintStream(outputStream) + val originalOut = System.out + + try { + System.setOut(printStream) + + functionCall.run() + + assertEquals(expectedStdOut, outputStream.toString().trim()) + } finally { + System.setOut(originalOut) + } + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/openmeteo/WeatherServiceTest.kt b/app/src/test/kotlin/openmeteo/WeatherServiceTest.kt new file mode 100644 index 0000000..da16702 --- /dev/null +++ b/app/src/test/kotlin/openmeteo/WeatherServiceTest.kt @@ -0,0 +1,133 @@ +package openmeteo + +import hu.vanio.kotlin.feladat.ms.openmeteo.Hourly +import hu.vanio.kotlin.feladat.ms.openmeteo.HourlyUnits +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherForecast +import hu.vanio.kotlin.feladat.ms.openmeteo.WeatherService +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.function.Executable +import java.time.LocalDate +import java.time.Month +import kotlin.test.assertEquals + +class WeatherServiceTest { + private val weatherForecastResponse = WeatherForecast(47.5, + 19.0625, + 0.030040740966796875, + 3600, + "Europe/Budapest", + "CET", + 124.0, + hourlyUnits = HourlyUnits("iso8601", "°C"), + hourly = Hourly(time = listOf("2024-03-18T00:00", + "2024-03-18T01:00", + "2024-03-18T02:00", + "2024-03-18T03:00", + "2024-03-18T04:00", + "2024-03-18T05:00", + "2024-03-18T06:00", + "2024-03-18T07:00", + "2024-03-18T08:00", + "2024-03-18T09:00", + "2024-03-18T10:00", + "2024-03-18T11:00", + "2024-03-18T12:00", + "2024-03-18T13:00", + "2024-03-18T14:00", + "2024-03-18T15:00", + "2024-03-18T16:00", + "2024-03-18T17:00", + "2024-03-18T18:00", + "2024-03-18T19:00", + "2024-03-18T20:00", + "2024-03-18T21:00", + "2024-03-18T22:00", + "2024-03-18T23:00", + "2024-03-19T00:00", + "2024-03-19T01:00", + "2024-03-19T02:00", + "2024-03-19T03:00", + "2024-03-19T04:00", + "2024-03-19T05:00", + "2024-03-19T06:00", + "2024-03-19T07:00", + "2024-03-19T08:00", + "2024-03-19T09:00", + "2024-03-19T10:00", + "2024-03-19T11:00", + "2024-03-19T12:00", + "2024-03-19T13:00", + "2024-03-19T14:00", + "2024-03-19T15:00", + "2024-03-19T16:00", + "2024-03-19T17:00", + "2024-03-19T18:00", + "2024-03-19T19:00", + "2024-03-19T20:00", + "2024-03-19T21:00", + "2024-03-19T22:00", + "2024-03-19T23:00"), + temperature2m = listOf(6.0, + 5.7, + 5.4, + 5.2, + 4.8, + 4.6, + 4.5, + 4.7, + 5.9, + 7.3, + 8.9, + 9.7, + 10.4, + 10.9, + 11.1, + 11.0, + 10.6, + 9.8, + 8.7, + 7.4, + 6.2, + 5.4, + 4.7, + 4.2, + 3.7, + 3.3, + 3.0, + 2.9, + 2.8, + 2.8, + 2.8, + 3.2, + 3.9, + 4.7, + 5.5, + 6.4, + 7.4, + 8.0, + 8.5, + 8.8, + 8.8, + 8.6, + 7.8, + 6.7, + 5.6, + 4.7, + 4.0, + 3.4)) + ) + + @Test + fun statistics() { + val weatherForecasts = WeatherService.statistics(weatherForecastResponse) + + Assertions.assertAll ( + Executable { assertEquals(2, weatherForecasts.weatherDailyForecast.size) } , + Executable { assertEquals(LocalDate.of(2024, Month.MARCH, 18), weatherForecasts.weatherDailyForecast[0].day) }, + Executable { assertEquals(7.21, weatherForecasts.weatherDailyForecast[0].average()) }, + Executable { assertEquals(LocalDate.of(2024, Month.MARCH, 19), weatherForecasts.weatherDailyForecast[1].day) }, + Executable { assertEquals(5.3, weatherForecasts.weatherDailyForecast[1].average()) } + ) + } +} \ No newline at end of file