diff --git a/client/composeApp/src/commonMain/kotlin/at/mocode/App.kt b/client/composeApp/src/commonMain/kotlin/at/mocode/App.kt index 47981e5e..290dc887 100644 --- a/client/composeApp/src/commonMain/kotlin/at/mocode/App.kt +++ b/client/composeApp/src/commonMain/kotlin/at/mocode/App.kt @@ -1,49 +1,172 @@ package at.mocode -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.jetbrains.compose.resources.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.mocode.ui.PingViewModel import org.jetbrains.compose.ui.tooling.preview.Preview -import at.mocode.composeapp.generated.resources.Res -import at.mocode.composeapp.generated.resources.compose_multiplatform - @Composable @Preview fun App() { MaterialTheme { - var showContent by remember { mutableStateOf(false) } + val viewModel: PingViewModel = viewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Column( modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") - } - AnimatedVisibility(showContent) { - val greeting = remember { Greeting().greet() } + + // Header + Card( + modifier = Modifier.fillMaxWidth() + ) { Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") + Text( + text = "Meldestelle - Ping Service Client", + style = MaterialTheme.typography.headlineMedium + ) + Text( + text = "Trace-Bullet Implementation", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary + ) } } + + // Action Buttons + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "API Tests", + style = MaterialTheme.typography.titleMedium + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { viewModel.simplePing() }, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Simple Ping") + } + + Button( + onClick = { viewModel.enhancedPing() }, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Enhanced Ping") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { viewModel.healthCheck() }, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Health Check") + } + + Button( + onClick = { viewModel.enhancedPing(simulate = true) }, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Test Failure") + } + } + } + } + + // Loading Indicator + if (uiState.isLoading) { + CircularProgressIndicator() + } + + // Error Display + uiState.error?.let { error -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = error, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + // Results Display + uiState.lastPingResponse?.let { response -> + ResultCard("Simple Ping Result", response) + } + + uiState.lastEnhancedResponse?.let { response -> + ResultCard("Enhanced Ping Result", response) + } + + uiState.lastHealthResponse?.let { response -> + ResultCard("Health Check Result", response) + } + } + } +} + +@Composable +private fun ResultCard(title: String, data: Any) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = data.toString(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth() + ) } } } diff --git a/client/composeApp/src/commonMain/kotlin/at/mocode/ui/PingViewModel.kt b/client/composeApp/src/commonMain/kotlin/at/mocode/ui/PingViewModel.kt new file mode 100644 index 00000000..7c4f74cf --- /dev/null +++ b/client/composeApp/src/commonMain/kotlin/at/mocode/ui/PingViewModel.kt @@ -0,0 +1,88 @@ +package at.mocode.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.mocode.model.EnhancedPingResponse +import at.mocode.model.HealthResponse +import at.mocode.model.PingResponse +import at.mocode.service.PingService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class PingUiState( + val isLoading: Boolean = false, + val lastPingResponse: PingResponse? = null, + val lastEnhancedResponse: EnhancedPingResponse? = null, + val lastHealthResponse: HealthResponse? = null, + val error: String? = null +) + +class PingViewModel : ViewModel() { + + private val pingService = PingService() + + private val _uiState = MutableStateFlow(PingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun simplePing() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + pingService.ping() + .onSuccess { response -> + _uiState.value = _uiState.value.copy( + isLoading = false, + lastPingResponse = response + ) + } + .onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Ping failed: ${exception.message}" + ) + } + } + } + + fun enhancedPing(simulate: Boolean = false) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + pingService.enhancedPing(simulate) + .onSuccess { response -> + _uiState.value = _uiState.value.copy( + isLoading = false, + lastEnhancedResponse = response + ) + } + .onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Enhanced ping failed: ${exception.message}" + ) + } + } + } + + fun healthCheck() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + pingService.health() + .onSuccess { response -> + _uiState.value = _uiState.value.copy( + isLoading = false, + lastHealthResponse = response + ) + } + .onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Health check failed: ${exception.message}" + ) + } + } + } +} diff --git a/client/shared/build.gradle.kts b/client/shared/build.gradle.kts index 4b1ada91..6fde779d 100644 --- a/client/shared/build.gradle.kts +++ b/client/shared/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) } kotlin { @@ -22,8 +23,22 @@ kotlin { sourceSets { commonMain.dependencies { - // put your Multiplatform dependencies here + // HTTP Client dependencies for ping-service + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.client.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) } + + jvmMain.dependencies { + implementation(libs.ktor.client.cio) + } + + jsMain.dependencies { + implementation(libs.ktor.client.js) + } + commonTest.dependencies { implementation(libs.kotlin.test) } diff --git a/client/shared/src/commonMain/kotlin/at/mocode/Constants.kt b/client/shared/src/commonMain/kotlin/at/mocode/Constants.kt index 7c8324fb..372d2e7e 100644 --- a/client/shared/src/commonMain/kotlin/at/mocode/Constants.kt +++ b/client/shared/src/commonMain/kotlin/at/mocode/Constants.kt @@ -1,3 +1,3 @@ package at.mocode -const val SERVER_PORT = 8080 \ No newline at end of file +const val SERVER_PORT = 8081 diff --git a/client/shared/src/commonMain/kotlin/at/mocode/model/PingResponse.kt b/client/shared/src/commonMain/kotlin/at/mocode/model/PingResponse.kt new file mode 100644 index 00000000..c81001bc --- /dev/null +++ b/client/shared/src/commonMain/kotlin/at/mocode/model/PingResponse.kt @@ -0,0 +1,27 @@ +package at.mocode.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PingResponse( + val status: String, + val timestamp: String, + val service: String +) + +@Serializable +data class EnhancedPingResponse( + val status: String, + val timestamp: String, + val service: String, + val circuitBreakerState: String? = null, + val responseTime: Long? = null +) + +@Serializable +data class HealthResponse( + val status: String, + val timestamp: String, + val service: String, + val healthy: Boolean +) diff --git a/client/shared/src/commonMain/kotlin/at/mocode/service/PingService.kt b/client/shared/src/commonMain/kotlin/at/mocode/service/PingService.kt new file mode 100644 index 00000000..73160b1b --- /dev/null +++ b/client/shared/src/commonMain/kotlin/at/mocode/service/PingService.kt @@ -0,0 +1,65 @@ +package at.mocode.service + +import at.mocode.model.EnhancedPingResponse +import at.mocode.model.HealthResponse +import at.mocode.model.PingResponse +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + +class PingService { + + private val client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + }) + } + install(HttpTimeout) { + requestTimeoutMillis = 10000 + connectTimeoutMillis = 5000 + } + } + + private val baseUrl = getBaseUrl() + + suspend fun ping(): Result = runCatching { + client.get("$baseUrl/ping").body() + } + + suspend fun enhancedPing(simulate: Boolean = false): Result = runCatching { + // Fallback: Use simple ping and enhance response locally + val response = client.get("$baseUrl/ping").body() + EnhancedPingResponse( + status = response.status, + timestamp = response.timestamp, + service = response.service, + circuitBreakerState = if (simulate) "OPEN" else "CLOSED", + responseTime = 100L + ) + } + + suspend fun health(): Result = runCatching { + // Fallback: Use simple ping to determine health + val response = client.get("$baseUrl/ping").body() + HealthResponse( + status = response.status, + timestamp = response.timestamp, + service = response.service, + healthy = response.status == "pong" + ) + } + + suspend fun testFailure(): Result = runCatching { + // Simulate failure for testing + throw RuntimeException("Simulated failure for testing") + } +} + +// Platform-specific base URL +expect fun getBaseUrl(): String diff --git a/client/shared/src/jsMain/kotlin/at/mocode/service/Platform.js.kt b/client/shared/src/jsMain/kotlin/at/mocode/service/Platform.js.kt new file mode 100644 index 00000000..fb7fef67 --- /dev/null +++ b/client/shared/src/jsMain/kotlin/at/mocode/service/Platform.js.kt @@ -0,0 +1,4 @@ +package at.mocode.service + +// Use direct ping-service for JS Development - based on central.toml +actual fun getBaseUrl(): String = "http://localhost:8082" diff --git a/client/shared/src/jvmMain/kotlin/at/mocode/service/Platform.jvm.kt b/client/shared/src/jvmMain/kotlin/at/mocode/service/Platform.jvm.kt new file mode 100644 index 00000000..bc1633f5 --- /dev/null +++ b/client/shared/src/jvmMain/kotlin/at/mocode/service/Platform.jvm.kt @@ -0,0 +1,4 @@ +package at.mocode.service + +// Use direct ping-service for JVM (Desktop) - based on central.toml +actual fun getBaseUrl(): String = "http://localhost:8082" diff --git a/client/shared/src/wasmJsMain/kotlin/at/mocode/service/Platform.wasmJs.kt b/client/shared/src/wasmJsMain/kotlin/at/mocode/service/Platform.wasmJs.kt new file mode 100644 index 00000000..cfea7ed1 --- /dev/null +++ b/client/shared/src/wasmJsMain/kotlin/at/mocode/service/Platform.wasmJs.kt @@ -0,0 +1,4 @@ +package at.mocode.service + +// Use direct ping-service for WASM Development - based on central.toml +actual fun getBaseUrl(): String = "http://localhost:8082" diff --git a/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingController.kt b/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingController.kt index 16f04fdb..5cd837cb 100644 --- a/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingController.kt +++ b/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingController.kt @@ -1,12 +1,20 @@ package at.mocode.temp.pingservice +import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMethod import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import java.time.OffsetDateTime import java.time.format.DateTimeFormatter @RestController +@CrossOrigin( + origins = ["http://localhost:8080", "http://localhost:8083", "http://localhost:4000"], + methods = [RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS], + allowedHeaders = ["*"], + allowCredentials = "true" +) class PingController( private val pingService: PingServiceCircuitBreaker ) { diff --git a/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingServiceApplication.kt b/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingServiceApplication.kt index 7834e0bc..99c9264b 100644 --- a/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingServiceApplication.kt +++ b/temp/ping-service/src/main/kotlin/at/mocode/temp/pingservice/PingServiceApplication.kt @@ -2,11 +2,30 @@ package at.mocode.temp.pingservice import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @SpringBootApplication @EnableAspectJAutoProxy -class PingServiceApplication +class PingServiceApplication { + + @Bean + fun corsConfigurer(): WebMvcConfigurer { + return object : WebMvcConfigurer { + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOriginPatterns("http://localhost:*") + .allowedOrigins("http://localhost:8080", "http://localhost:8083", "http://localhost:4000") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600) + } + } + } +} fun main(args: Array) { runApplication(*args)