module 5 lesson 1

This commit is contained in:
Александр Веденёв
2025-03-17 22:26:48 +07:00
parent 34636aaf60
commit d0bc6821d2
68 changed files with 1945 additions and 45 deletions

View File

@ -20,6 +20,7 @@ subprojects {
ext {
val specDir = layout.projectDirectory.dir("../specs")
set("spec-v1", specDir.file("specs-v1.yaml").toString())
set("spec-log-v1", specDir.file("specs-log-v1.yaml").toString())
}
tasks {

View File

@ -0,0 +1,58 @@
plugins {
id("build-jvm")
alias(libs.plugins.openapi.generator)
}
sourceSets {
main {
java.srcDir(layout.buildDirectory.dir("generate-resources/main/src/main/kotlin"))
}
}
openApiGenerate {
val openapiGroup = "${rootProject.group}.api.log.v1"
generatorName.set("kotlin")
packageName.set(openapiGroup)
apiPackage.set("$openapiGroup.api")
modelPackage.set("$openapiGroup.models")
inputSpec.set(rootProject.ext["spec-log-v1"] as String)
/**
* Use only models
* Doc: https://openapi-generator.tech/docs/globals
*/
globalProperties.apply {
put("models", "")
put("modelDocs", "false")
}
/**
* Additional parameters from
* https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/kotlin.md
*/
configOptions.set(
mapOf(
"dateLibrary" to "string",
"enumPropertyNaming" to "UPPERCASE",
"serializationLibrary" to "jackson",
"collectionType" to "list"
)
)
}
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.kotlin.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.jackson.kotlin)
implementation(libs.jackson.datatype)
implementation(project(":ok-messenger-common"))
testImplementation(kotlin("test-junit"))
}
tasks {
filter { it.name.startsWith("compile") }.forEach {
it.dependsOn(openApiGenerate)
}
}

View File

@ -0,0 +1,47 @@
package ru.otus.messenger.api.log.v1.mapper
import kotlinx.datetime.Clock
import ru.otus.messenger.api.log.v1.models.*
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.models.*
fun MessengerContext.toLog(logId: String) = CommonLogModel(
messageTime = Clock.System.now().toString(),
logId = logId,
source = "ok-messenger",
chat = toChatLog(),
errors = errors.map { it.toLog() },
)
private fun MessengerContext.toChatLog(): ChatLogModel? {
val emptyReport = MessengerChat()
return ChatLogModel(
requestId = requestId.takeIf { it != RequestId.NONE }?.asString(),
requestChat = chatRequest.takeIf { it != emptyReport }?.toLog(),
requestSearch = chatFilterRequest.takeIf { it != ChatSearchFilter.NONE }?.toLog(),
responseChat = chatResponse.takeIf { it != emptyReport }?.toLog(),
responseChats = chatsResponse.takeIf { it.isNotEmpty() }?.filter { it != emptyReport }?.map { it.toLog() },
).takeIf { it != ChatLogModel() }
}
private fun ChatSearchFilter.toLog() = ChatSearchLog(
searchFields = searchFields.joinToString("\t") { it.toString() },
)
private fun ChatError.toLog() = ErrorLogModel(
message = message.takeIf { it.isNotBlank() },
field = field.takeIf { it.isNotBlank() },
code = code.takeIf { it.isNotBlank() },
level = level.name,
)
private fun MessengerChat.toLog() = ChatLog(
chatId = id.takeIf { it != ChatId.NONE }?.asString(),
title = title.takeIf { it.isNotBlank() },
description = description.takeIf { it.isNotBlank() },
type = type.takeIf { it != ChatType.NONE }.toString(),
mode = mode.takeIf { it != ChatMode.NONE }.toString(),
ownerId = ownerId.takeIf { it != ChatOwnerId.NONE }?.asString(),
participants = participants.takeIf { it.isNotEmpty() }?.map { it.asString() }?.toSet(),
metadata = metadata.takeIf { it != ChatMetadata.NONE }?.asString()
)

View File

@ -5,10 +5,10 @@ import kotlinx.serialization.json.jsonObject
import ru.otus.messenger.api.v1.mappers.exceptions.UnknownRequestClass
import ru.otus.messenger.api.v1.models.*
import ru.otus.messenger.common.models.*
import ru.otus.messenger.common.ChatContext
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.stubs.Stubs
fun ChatContext.fromTransport(request: IRequest) = when (request) {
fun MessengerContext.fromTransport(request: IRequest) = when (request) {
is ChatCreateRequest -> fromTransport(request)
is ChatReadRequest -> fromTransport(request)
is ChatDeleteRequest -> fromTransport(request)
@ -40,35 +40,35 @@ private fun Debug?.transportToStubCase(): Stubs = when (this?.stub) {
null -> Stubs.NONE
}
fun ChatContext.fromTransport(request: ChatCreateRequest) {
fun MessengerContext.fromTransport(request: ChatCreateRequest) {
command = ChatCommand.CREATE
chatRequest = request.chat?.toInternal() ?: MessengerChat()
workMode = request.debug.transportToWorkMode()
stubCase = request.debug.transportToStubCase()
}
fun ChatContext.fromTransport(request: ChatReadRequest) {
fun MessengerContext.fromTransport(request: ChatReadRequest) {
command = ChatCommand.READ
chatRequest = request.chatId.toChatId().toInternal()
workMode = request.debug.transportToWorkMode()
stubCase = request.debug.transportToStubCase()
}
fun ChatContext.fromTransport(request: ChatDeleteRequest) {
fun MessengerContext.fromTransport(request: ChatDeleteRequest) {
command = ChatCommand.DELETE
chatRequest = request.chatId.toChatId().toInternal()
workMode = request.debug.transportToWorkMode()
stubCase = request.debug.transportToStubCase()
}
fun ChatContext.fromTransport(request: ChatUpdateRequest) {
fun MessengerContext.fromTransport(request: ChatUpdateRequest) {
command = ChatCommand.UPDATE
chatRequest = request.chat?.toInternal() ?: MessengerChat()
workMode = request.debug.transportToWorkMode()
stubCase = request.debug.transportToStubCase()
}
fun ChatContext.fromTransport(request: ChatSearchRequest) {
fun MessengerContext.fromTransport(request: ChatSearchRequest) {
command = ChatCommand.SEARCH
chatRequest = request.criteria?.toInternal() ?: MessengerChat()
workMode = request.debug.transportToWorkMode()

View File

@ -3,44 +3,56 @@ package ru.otus.messenger.api.v1.mappers
import kotlinx.datetime.Instant
import ru.otus.messenger.api.v1.models.*
import ru.otus.messenger.common.models.*
import ru.otus.messenger.common.ChatContext
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.NONE
import ru.otus.messenger.common.exceptions.UnknownChatCommand
fun ChatContext.toTransportChat(): IResponse = when (val cmd = command) {
fun MessengerContext.toTransportChat(): IResponse = when (val cmd = command) {
ChatCommand.CREATE -> toTransportCreate()
ChatCommand.READ -> toTransportRead()
ChatCommand.DELETE -> toTransportDelete()
ChatCommand.SEARCH -> toTransportSearch()
ChatCommand.UPDATE -> toTransportUpdate()
ChatCommand.INIT -> toTransportInit()
ChatCommand.FINISH -> toTransportFinish()
ChatCommand.NONE -> throw UnknownChatCommand(cmd)
}
fun ChatContext.toTransportCreate() = ChatCreateResponse(
fun MessengerContext.toTransportInit() = ChatInitResponse(
result = state.toResult(),
errors = errors.toTransportErrors(),
)
fun MessengerContext.toTransportFinish() = ChatInitResponse(
result = state.toResult(),
errors = errors.toTransportErrors(),
)
fun MessengerContext.toTransportCreate() = ChatCreateResponse(
result = state.toResult(),
errors = errors.toTransportErrors(),
chat = chatResponse.toTransportChat()
)
fun ChatContext.toTransportRead() = ChatReadResponse(
fun MessengerContext.toTransportRead() = ChatReadResponse(
result = state.toResult(),
errors = errors.toTransportErrors(),
chat = chatResponse.toTransportChat()
)
fun ChatContext.toTransportDelete() = ChatDeleteResponse(
fun MessengerContext.toTransportDelete() = ChatDeleteResponse(
result = state.toResult(),
errors = errors.toTransportErrors(),
)
fun ChatContext.toTransportSearch() = ChatSearchResponse(
fun MessengerContext.toTransportSearch() = ChatSearchResponse(
result = state.toResult(),
errors = errors.toTransportErrors(),
chats = chatsResponse.toTransportChats()
)
fun ChatContext.toTransportUpdate() = ChatUpdateResponse(
fun MessengerContext.toTransportUpdate() = ChatUpdateResponse(
result = state.toResult(),
errors = errors.toTransportErrors(),
chat = chatResponse.toTransportChat()

View File

@ -10,7 +10,7 @@ import ru.otus.messenger.api.v1.models.ChatCreateResponse
import ru.otus.messenger.api.v1.models.Debug
import ru.otus.messenger.api.v1.models.DebugMode
import ru.otus.messenger.api.v1.models.DebugStubs
import ru.otus.messenger.common.ChatContext
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.models.ChatCommand
import ru.otus.messenger.common.models.ChatError
import ru.otus.messenger.common.models.ChatMode
@ -39,7 +39,7 @@ class MapperCreateTest {
)
)
val context = ChatContext()
val context = MessengerContext()
context.fromTransport(req)
assertEquals(Stubs.SUCCESS, context.stubCase)
@ -51,7 +51,7 @@ class MapperCreateTest {
@Test
fun toTransport() {
val context = ChatContext(
val context = MessengerContext(
requestId = RequestId(UUID.randomUUID().toString()),
command = ChatCommand.CREATE,
state = ChatState.RUNNING,

View File

@ -9,7 +9,7 @@ import ru.otus.messenger.api.v1.models.Debug
import ru.otus.messenger.api.v1.models.DebugMode
import ru.otus.messenger.api.v1.models.DebugStubs
import ru.otus.messenger.api.v1.models.ResponseResult
import ru.otus.messenger.common.ChatContext
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.models.ChatCommand
import ru.otus.messenger.common.models.ChatError
import ru.otus.messenger.common.models.ChatMode
@ -30,7 +30,7 @@ class MapperDeleteTest {
chatId = "chat-id",
)
val context = ChatContext()
val context = MessengerContext()
context.fromTransport(req)
assertEquals(Stubs.SUCCESS, context.stubCase)
@ -43,7 +43,7 @@ class MapperDeleteTest {
@Test
fun toTransport() {
val context = ChatContext(
val context = MessengerContext(
requestId = RequestId(UUID.randomUUID().toString()),
command = ChatCommand.DELETE,
state = ChatState.RUNNING,

View File

@ -9,7 +9,7 @@ import ru.otus.messenger.api.v1.models.ChatReadResponse
import ru.otus.messenger.api.v1.models.Debug
import ru.otus.messenger.api.v1.models.DebugMode
import ru.otus.messenger.api.v1.models.DebugStubs
import ru.otus.messenger.common.ChatContext
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.models.ChatCommand
import ru.otus.messenger.common.models.ChatError
import ru.otus.messenger.common.models.ChatMode
@ -32,7 +32,7 @@ class MapperReadTest {
chatId = "chat-id",
)
val context = ChatContext()
val context = MessengerContext()
context.fromTransport(req)
assertEquals(Stubs.SUCCESS, context.stubCase)
@ -45,7 +45,7 @@ class MapperReadTest {
@Test
fun toTransport() {
val context = ChatContext(
val context = MessengerContext(
requestId = RequestId(UUID.randomUUID().toString()),
command = ChatCommand.READ,
state = ChatState.RUNNING,

View File

@ -10,7 +10,7 @@ import ru.otus.messenger.api.v1.models.ChatSearchResponse
import ru.otus.messenger.api.v1.models.Debug
import ru.otus.messenger.api.v1.models.DebugMode
import ru.otus.messenger.api.v1.models.DebugStubs
import ru.otus.messenger.common.ChatContext
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.models.ChatCommand
import ru.otus.messenger.common.models.ChatError
import ru.otus.messenger.common.models.ChatMode
@ -38,7 +38,7 @@ class MapperSearchTest {
)
)
val context = ChatContext()
val context = MessengerContext()
context.fromTransport(req)
assertEquals(Stubs.SUCCESS, context.stubCase)
@ -50,7 +50,7 @@ class MapperSearchTest {
@Test
fun toTransport() {
val context = ChatContext(
val context = MessengerContext(
requestId = RequestId(UUID.randomUUID().toString()),
command = ChatCommand.SEARCH,
state = ChatState.RUNNING,

View File

@ -10,7 +10,7 @@ import ru.otus.messenger.api.v1.models.ChatUpdateResponse
import ru.otus.messenger.api.v1.models.Debug
import ru.otus.messenger.api.v1.models.DebugMode
import ru.otus.messenger.api.v1.models.DebugStubs
import ru.otus.messenger.common.ChatContext
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.models.ChatCommand
import ru.otus.messenger.common.models.ChatError
import ru.otus.messenger.common.models.ChatMode
@ -44,7 +44,7 @@ class MapperUpdateTest {
)
)
val context = ChatContext()
val context = MessengerContext()
context.fromTransport(req)
assertEquals(Stubs.SUCCESS, context.stubCase)
@ -57,7 +57,7 @@ class MapperUpdateTest {
@Test
fun toTransport() {
val context = ChatContext(
val context = MessengerContext(
requestId = RequestId(UUID.randomUUID().toString()),
command = ChatCommand.UPDATE,
state = ChatState.RUNNING,

View File

@ -44,8 +44,9 @@ openApiGenerate {
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.kotlin.jackson.module)
implementation(libs.kotlin.jackson.datatype)
implementation(libs.kotlin.datetime)
implementation(libs.jackson.kotlin)
implementation(libs.jackson.datatype)
testImplementation(kotlin("test-junit"))
}

View File

@ -0,0 +1,24 @@
# Модуль ok-messenger-app
Ktor server application
## Building & Running
To build or run the project, use one of the following tasks:
| Task | Description |
|-------------------------------|----------------------------------------------------------------------|
| `./gradlew test` | Run the tests |
| `./gradlew build` | Build everything |
| `buildFatJar` | Build an executable JAR of the server with all dependencies included |
| `buildImage` | Build the docker image to use with the fat JAR |
| `publishImageToLocalRegistry` | Publish the docker image locally |
| `run` | Run the server |
| `runDocker` | Run using the local docker image |
If the server starts successfully, you'll see the following output:
```
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
```

View File

@ -0,0 +1,55 @@
plugins {
id("build-jvm")
alias(libs.plugins.ktor)
alias(libs.plugins.muschko.remote)
}
application {
mainClass.set("io.ktor.server.cio.EngineMain")
}
ktor {
docker {
localImageName.set(project.name)
imageTag.set(project.version.toString())
jreVersion.set(JavaVersion.VERSION_21)
}
}
jib {
container.mainClass = application.mainClass.get()
}
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.yaml)
implementation(libs.ktor.server.negotiation)
implementation(libs.ktor.server.headers.default)
implementation(libs.ktor.server.headers.response)
implementation(libs.ktor.server.headers.caching)
implementation(libs.ktor.serialization.jackson)
implementation(libs.ktor.server.calllogging)
implementation(libs.ktor.server.websocket)
// transport models
implementation(project(":ok-messenger-common"))
implementation(project(":ok-messenger-api-v1"))
implementation(project(":ok-messenger-api-v1-mappers"))
// logic
implementation(project(":ok-messenger-biz"))
// stubs
implementation(project(":ok-messenger-stubs"))
// logging
implementation(project(":ok-messenger-api-log-v1"))
implementation("ru.otus.messenger.libs:ok-messenger-lib-logging")
testImplementation(kotlin("test-junit"))
testImplementation(libs.ktor.server.test)
testImplementation(libs.ktor.client.negotiation)
}

View File

@ -0,0 +1,33 @@
package ru.otus.messenger.app
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.netty.EngineMain
import io.ktor.server.plugins.cors.routing.*
import ru.otus.messenger.app.common.MessengerAppSettings
import ru.otus.messenger.app.plugins.initAppSettings
fun main(args: Array<String>) = EngineMain.main(args)
fun Application.module(
appSettings: MessengerAppSettings = initAppSettings(),
) {
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
allowHeader("MyCustomHeader")
allowCredentials = true
/* TODO
Это временное решение, оно опасно.
В боевом приложении здесь должны быть конкретные настройки
*/
anyHost()
}
configureHTTP()
configureSerialization()
configureRouting(appSettings)
}

View File

@ -0,0 +1,10 @@
package ru.otus.messenger.app
import io.ktor.server.application.*
import io.ktor.server.plugins.cachingheaders.*
import io.ktor.server.plugins.defaultheaders.*
fun Application.configureHTTP() {
install(CachingHeaders)
install(DefaultHeaders)
}

View File

@ -0,0 +1,11 @@
package ru.otus.messenger.app
import io.ktor.server.application.*
import io.ktor.server.plugins.calllogging.*
import org.slf4j.event.Level
fun Application.configureMonitoring() {
install(CallLogging) {
level = Level.INFO
}
}

View File

@ -0,0 +1,29 @@
package ru.otus.messenger.app
import io.ktor.server.application.*
import io.ktor.server.plugins.autohead.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import ru.otus.messenger.app.common.MessengerAppSettings
import ru.otus.messenger.app.v1.v1Chat
import ru.otus.messenger.app.v1.wsHandlerV1
fun Application.configureRouting(appSettings: MessengerAppSettings) {
install(AutoHeadResponse)
install(WebSockets)
routing {
get("/") {
call.respondText("Hello World!")
}
route("v1") {
v1Chat(appSettings)
webSocket("/ws") {
wsHandlerV1(appSettings)
}
}
}
}

View File

@ -0,0 +1,15 @@
package ru.otus.messenger.app
import io.ktor.serialization.jackson.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import ru.otus.messenger.api.v1.apiV1Mapper
fun Application.configureSerialization() {
install(ContentNegotiation) {
jackson {
setConfig(apiV1Mapper.serializationConfig)
setConfig(apiV1Mapper.deserializationConfig)
}
}
}

View File

@ -0,0 +1,23 @@
package ru.otus.messenger.app.base
import ru.otus.messenger.common.ws.IMessengerWsSession
import ru.otus.messenger.common.ws.IMessengerWsSessionRepo
class KtorWsSessionRepo(): IMessengerWsSessionRepo {
private val sessions: MutableSet<IMessengerWsSession> = mutableSetOf()
override fun add(session: IMessengerWsSession) {
sessions.add(session)
}
override fun clearAll() {
sessions.clear()
}
override fun remove(session: IMessengerWsSession) {
sessions.remove(session)
}
override suspend fun <T> sendAll(obj: T) {
sessions.forEach { it.send(obj) }
}
}

View File

@ -0,0 +1,16 @@
package ru.otus.messenger.app.base
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import ru.otus.messenger.api.v1.apiV1ResponseSerialize
import ru.otus.messenger.api.v1.models.IResponse
import ru.otus.messenger.common.ws.IMessengerWsSession
data class KtorWsSessionV1(
private val session: WebSocketSession
) : IMessengerWsSession {
override suspend fun <T> send(obj: T) {
require(obj is IResponse)
session.send(Frame.Text(apiV1ResponseSerialize(obj)))
}
}

View File

@ -0,0 +1,50 @@
package ru.otus.messenger.app.common
import kotlinx.datetime.Clock
import ru.otus.messenger.api.log.v1.mapper.toLog
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.helpers.asMessengerError
import ru.otus.messenger.common.models.ChatCommand
import ru.otus.messenger.common.models.ChatState
import kotlin.reflect.KClass
suspend inline fun <T> MessengerAppSettings.controllerHelper(
crossinline getRequest: suspend MessengerContext.() -> Unit,
crossinline toResponse: suspend MessengerContext.() -> T,
clazz: KClass<*>,
logId: String,
): T {
val logger = corSettings.loggerProvider.logger(clazz)
val ctx = MessengerContext(
timeStart = Clock.System.now(),
)
return try {
ctx.getRequest()
logger.info(
msg = "Request $logId started for ${clazz.simpleName}",
marker = "BIZ",
data = ctx.toLog(logId)
)
processor.exec(ctx)
logger.info(
msg = "Request $logId processed for ${clazz.simpleName}",
marker = "BIZ",
data = ctx.toLog(logId)
)
ctx.toResponse()
} catch (e: Throwable) {
logger.error(
msg = "Request $logId failed for ${clazz.simpleName}",
marker = "BIZ",
data = ctx.toLog(logId),
e = e,
)
ctx.state = ChatState.FAILING
ctx.errors.add(e.asMessengerError())
processor.exec(ctx)
if (ctx.command == ChatCommand.NONE) {
ctx.command = ChatCommand.READ
}
ctx.toResponse()
}
}

View File

@ -0,0 +1,9 @@
package ru.otus.messenger.app.common
import ru.otus.messenger.biz.MessengerProcessor
import ru.otus.messenger.common.MessengerCorSettings
interface MessengerAppSettings {
val processor: MessengerProcessor
val corSettings: MessengerCorSettings
}

View File

@ -0,0 +1,10 @@
package ru.otus.messenger.app.common
import ru.otus.messenger.biz.MessengerProcessor
import ru.otus.messenger.common.MessengerCorSettings
data class MessengerAppSettingsData(
val appUrls: List<String> = emptyList(),
override val corSettings: MessengerCorSettings = MessengerCorSettings(),
override val processor: MessengerProcessor = MessengerProcessor(corSettings),
): MessengerAppSettings

View File

@ -0,0 +1,7 @@
package ru.otus.messenger.app.plugins
import io.ktor.server.application.*
import ru.otus.messenger.logging.common.LoggerProvider
import ru.otus.messenger.logging.loggerLogback
fun Application.getLoggerProviderConf(): LoggerProvider = LoggerProvider { loggerLogback(it) }

View File

@ -0,0 +1,18 @@
package ru.otus.messenger.app.plugins
import io.ktor.server.application.*
import ru.otus.messenger.app.common.MessengerAppSettings
import ru.otus.messenger.app.common.MessengerAppSettingsData
import ru.otus.messenger.biz.MessengerProcessor
import ru.otus.messenger.common.MessengerCorSettings
fun Application.initAppSettings(): MessengerAppSettings {
val corSettings = MessengerCorSettings(
loggerProvider = getLoggerProviderConf(),
)
return MessengerAppSettingsData(
appUrls = environment.config.propertyOrNull("ktor.urls")?.getList() ?: emptyList(),
corSettings = corSettings,
processor = MessengerProcessor(corSettings),
)
}

View File

@ -0,0 +1,22 @@
package ru.otus.messenger.app.v1
import io.ktor.server.application.*
import ru.otus.messenger.api.v1.models.*
import ru.otus.messenger.app.common.MessengerAppSettings
import kotlin.reflect.KClass
val clCreate: KClass<*> = ApplicationCall::createChat::class
suspend fun ApplicationCall.createChat(appSettings: MessengerAppSettings) =
processV1<ChatCreateRequest, ChatCreateResponse>(appSettings, clCreate,"create")
val clRead: KClass<*> = ApplicationCall::readChat::class
suspend fun ApplicationCall.readChat(appSettings: MessengerAppSettings) =
processV1<ChatReadRequest, ChatReadResponse>(appSettings, clRead, "read")
val clDelete: KClass<*> = ApplicationCall::deleteChat::class
suspend fun ApplicationCall.deleteChat(appSettings: MessengerAppSettings) =
processV1<ChatDeleteRequest, ChatDeleteResponse>(appSettings, clDelete, "delete")
val clSearch: KClass<*> = ApplicationCall::searchChat::class
suspend fun ApplicationCall.searchChat(appSettings: MessengerAppSettings) =
processV1<ChatSearchRequest, ChatSearchResponse>(appSettings, clSearch, "search")

View File

@ -0,0 +1,25 @@
package ru.otus.messenger.app.v1
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import ru.otus.messenger.api.v1.models.IRequest
import ru.otus.messenger.api.v1.models.IResponse
import ru.otus.messenger.app.common.controllerHelper
import ru.otus.messenger.app.common.MessengerAppSettings
import ru.otus.messenger.api.v1.mappers.fromTransport
import ru.otus.messenger.api.v1.mappers.toTransportChat
import kotlin.reflect.KClass
suspend inline fun <reified Q : IRequest, @Suppress("unused") reified R : IResponse> ApplicationCall.processV1(
appSettings: MessengerAppSettings,
clazz: KClass<*>,
logId: String,
) = appSettings.controllerHelper(
{
fromTransport(receive<Q>())
},
{ respond(toTransportChat()) },
clazz,
logId,
)

View File

@ -0,0 +1,21 @@
package ru.otus.messenger.app.v1
import io.ktor.server.routing.*
import ru.otus.messenger.app.common.MessengerAppSettings
fun Route.v1Chat(appSettings: MessengerAppSettings) {
route("chat") {
post("create") {
call.createChat(appSettings)
}
post("read") {
call.readChat(appSettings)
}
post("delete") {
call.deleteChat(appSettings)
}
post("search") {
call.searchChat(appSettings)
}
}
}

View File

@ -0,0 +1,71 @@
package ru.otus.messenger.app.v1
import com.fasterxml.jackson.module.kotlin.readValue
import io.ktor.websocket.*
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.receiveAsFlow
import ru.otus.messenger.api.v1.apiV1Mapper
import ru.otus.messenger.api.v1.mappers.fromTransport
import ru.otus.messenger.api.v1.mappers.toTransportInit
import ru.otus.messenger.api.v1.mappers.toTransportChat
import ru.otus.messenger.api.v1.models.IRequest
import ru.otus.messenger.app.base.KtorWsSessionV1
import ru.otus.messenger.app.common.MessengerAppSettings
import ru.otus.messenger.app.common.controllerHelper
import ru.otus.messenger.common.models.ChatCommand
import kotlin.reflect.KClass
private val clWsV1: KClass<*> = WebSocketSession::wsHandlerV1::class
suspend fun WebSocketSession.wsHandlerV1(appSettings: MessengerAppSettings) = with(KtorWsSessionV1(this)) {
val sessions = appSettings.corSettings.wsSessions
sessions.add(this)
// Handle init request
appSettings.controllerHelper(
{
command = ChatCommand.INIT
wsSession = this@with
},
{ outgoing.send(Frame.Text(apiV1Mapper.writeValueAsString(toTransportInit()))) },
clWsV1,
"wsV1-init"
)
// Handle flow
incoming.receiveAsFlow().mapNotNull {
val frame = it as? Frame.Text ?: return@mapNotNull
// Handle without flow destruction
try {
appSettings.controllerHelper(
{
fromTransport(apiV1Mapper.readValue<IRequest>(frame.readText()))
wsSession = this@with
},
{
val result = apiV1Mapper.writeValueAsString(toTransportChat())
// If change request, response is sent to everyone
outgoing.send(Frame.Text(result))
},
clWsV1,
"wsV1-handle"
)
} catch (_: ClosedReceiveChannelException) {
sessions.remove(this@with)
} finally {
// Handle finish request
appSettings.controllerHelper(
{
command = ChatCommand.FINISH
wsSession = this@with
},
{ },
clWsV1,
"wsV1-finish"
)
sessions.remove(this@with)
}
}.collect()
}

View File

@ -0,0 +1,10 @@
ktor:
development: true
deployment:
port: 8080
watch:
- classes
- resources
application:
modules:
- ru.otus.messenger.app.ApplicationKt.module

View File

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration>
<configuration scan="true" scanPeriod="30 seconds" debug="false">
<property name="LOGS_FB_HOSTS" value="${LOGS_FB_HOSTS:-127.0.0.1}"/>
<property name="LOGS_FB_PORT" value="${LOGS_FB_PORT:-24224}"/>
<property name="SERVICE_NAME" value="${SERVICE_NAME:-ok_messenger}"/>
<property name="LOG_OTUS_LEVEL" value="${LOG_OTUS_LEVEL:-info}"/>
<property name="LOG_COMMON_LEVEL" value="${LOG_COMMON_LEVEL:-error}"/>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level[%marker] %logger{36} - %msg%n%mdc%n</pattern>
</encoder>
</appender>
<!-- <if condition='!property("LOGS_FB_HOSTS").equals("LOGS_FB_HOSTS_IS_UNDEFINED")-->
<!-- &amp;&amp; !property("LOGS_FB_HOSTS").isEmpty()'>-->
<!-- <then>-->
<!-- <if condition='!property("LOGS_FB_HOSTS").equals("LOGS_FB_HOSTS_IS_UNDEFINED")-->
<!-- &amp;&amp; !property("LOGS_FB_HOSTS").isEmpty()'>-->
<!-- <then>-->
<appender name="fluentd" class="ch.qos.logback.more.appenders.DataFluentAppender">
<tag>app.logs</tag>
<label>normal</label>
<remoteHost>${LOGS_FB_HOSTS}</remoteHost>
<port>${LOGS_FB_PORT}</port>
<maxQueueSize>20</maxQueueSize>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/>
<version/>
<pattern>
<pattern>
{
"component": "${SERVICE_NAME}",
"container-id": "${HOSTNAME}"
}
</pattern>
</pattern>
<message/>
<loggerName/>
<threadName/>
<logLevel/>
<logstashMarkers/>
<callerData/>
<stackTrace/>
<context/>
<mdc/>
<logstashMarkers/>
<arguments/>
<tags/>
</providers>
</encoder>
</appender>
<!-- </then>-->
<!-- </if>-->
<!-- &lt;!&ndash; For ELK-Stack: Kafka log's host &ndash;&gt;-->
<!-- <property name="LOGS_KAFKA_HOSTS" value="${BOOTSTRAP_SERVERS:-localhost:9094}"/>-->
<!-- &lt;!&ndash; For ELK-Stack: Kafka log's topic &ndash;&gt;-->
<!-- <property name="LOGS_KAFKA_TOPIC" value="${LOGS_KAFKA_TOPIC:-ok-mkpl-logs}"/>-->
<!-- <appender name="asyncMyLogKafka"-->
<!-- class="net.logstash.logback.appender.LoggingEventAsyncDisruptorAppender">-->
<!-- <if condition='!property("LOGS_KAFKA_HOSTS").equals("LOGS_KAFKA_HOSTS_IS_UNDEFINED")-->
<!-- &amp;&amp; !property("LOGS_KAFKA_HOSTS").isEmpty()'>-->
<!-- <then>-->
<!-- <appender name="kafkaVerboseAppender"-->
<!-- class="com.github.danielwegener.logback.kafka.KafkaAppender">-->
<!-- <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">-->
<!-- <providers>-->
<!-- <timestamp/>-->
<!-- <version/>-->
<!-- <pattern>-->
<!-- <pattern>-->
<!-- {-->
<!-- "component": "${SERVICE_NAME}",-->
<!-- "container-id": "${HOSTNAME}"-->
<!-- }-->
<!-- </pattern>-->
<!-- </pattern>-->
<!-- <message/>-->
<!-- <loggerName/>-->
<!-- <threadName/>-->
<!-- <logLevel/>-->
<!-- <logstashMarkers/>-->
<!-- <callerData/>-->
<!-- <stackTrace/>-->
<!-- <context/>-->
<!-- <mdc/>-->
<!-- <logstashMarkers/>-->
<!-- <arguments/>-->
<!-- <tags/>-->
<!-- </providers>-->
<!-- </encoder>-->
<!-- <topic>${LOGS_KAFKA_TOPIC}</topic>-->
<!-- <deliveryStrategy-->
<!-- class="com.github.danielwegener.logback.kafka.delivery.AsynchronousDeliveryStrategy"/>-->
<!-- <producerConfig>bootstrap.servers=${LOGS_KAFKA_HOSTS}</producerConfig>-->
<!-- </appender>-->
<!-- </then>-->
<!-- </if>-->
<!-- </appender>-->
<logger name="ru.otus" level="${LOG_OTUS_LEVEL}" additivity="false">
<appender-ref ref="fluentd"/>
<!-- <appender-ref ref="asyncMyLogKafka"/>-->
<appender-ref ref="STDOUT"/>
</logger>
<logger name="Application" level="INFO">
<appender-ref ref="STDOUT"/>
</logger>
<root level="${LOG_COMMON_LEVEL}">
<appender-ref ref="fluentd"/>
<!-- <appender-ref ref="asyncMyLogKafka"/>-->
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@ -0,0 +1,21 @@
package ru.otus.messenger.app
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlin.test.assertEquals
import org.junit.Test
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
client.get("/").apply {
assertEquals(HttpStatusCode.Companion.OK, status)
}
}
}

View File

@ -0,0 +1,64 @@
package ru.otus.messenger.app.common
import java.util.UUID
import kotlinx.coroutines.test.runTest
import ru.otus.messenger.api.v1.mappers.fromTransport
import ru.otus.messenger.api.v1.mappers.toTransportChat
import ru.otus.messenger.api.v1.models.*
import ru.otus.messenger.biz.MessengerProcessor
import ru.otus.messenger.common.MessengerCorSettings
import kotlin.test.Test
import kotlin.test.assertEquals
class ControllerTest {
private val request = ChatCreateRequest(
chat = ChatCreateRequestAllOfChat(
title = "New chat title",
description = "New chat description",
type = ChatCreateRequestAllOfChat.Type.GROUP,
mode = ChatCreateRequestAllOfChat.Mode.PERSONAL,
ownerId = UUID.randomUUID().toString(),
participants = setOf(),
metadata = """
{
"organization": "BlancLabs",
"sampleName": "B26",
"analyte": "DNA"
}
""".trimIndent()
),
debug = Debug(mode = DebugMode.STUB, stub = DebugStubs.SUCCESS)
)
private val appSettings: MessengerAppSettings = object : MessengerAppSettings {
override val corSettings: MessengerCorSettings = MessengerCorSettings()
override val processor: MessengerProcessor = MessengerProcessor(corSettings)
}
class TestApplicationCall(private val request: IRequest) {
var response: IResponse? = null
@Suppress("UNCHECKED_CAST")
fun <T : IRequest> receive(): T = request as T
fun respond(response: IResponse) {
this.response = response
}
}
private suspend fun TestApplicationCall.createReport(appSettings: MessengerAppSettings) {
val response = appSettings.controllerHelper(
{ fromTransport(receive<ChatCreateRequest>()) },
{ toTransportChat() },
ControllerTest::class,
"controller-v1-test"
)
respond(response)
}
@Test
fun ktorHelperTest() = runTest {
val testApp = TestApplicationCall(request).apply { createReport(appSettings) }
val response = testApp.response as ChatCreateResponse
assertEquals(ResponseResult.SUCCESS, response.result)
}
}

View File

@ -0,0 +1,2 @@
package ru.otus.messenger.app.stub

View File

@ -0,0 +1,119 @@
package ru.otus.messenger.app.websocket
import io.ktor.client.plugins.websocket.*
import io.ktor.serialization.jackson.*
import io.ktor.server.testing.*
import java.util.UUID
import kotlinx.coroutines.withTimeout
import ru.otus.messenger.api.v1.models.*
import ru.otus.messenger.app.common.MessengerAppSettingsData
import ru.otus.messenger.app.module
import ru.otus.messenger.common.MessengerCorSettings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
class V1WebsocketStubTest {
@Test
fun createStub() {
val request = ChatCreateRequest(
chat = ChatCreateRequestAllOfChat(
title = "New chat title",
description = "New chat description",
type = ChatCreateRequestAllOfChat.Type.CHANNEL,
mode = ChatCreateRequestAllOfChat.Mode.WORK,
ownerId = UUID.randomUUID().toString(),
participants = setOf(UUID.randomUUID().toString()),
metadata = """
{
"organization": "BlancLabs",
"sampleName": "B26",
"analyte": "DNA"
}
""".trimIndent(),
),
debug = Debug(
mode = DebugMode.STUB,
stub = DebugStubs.SUCCESS
)
)
testMethod<IResponse>(request) {
assertEquals(ResponseResult.SUCCESS, it.result)
}
}
@Test
fun readStub() {
val request = ChatReadRequest(
chatId = UUID.randomUUID().toString(),
debug = Debug(
mode = DebugMode.STUB,
stub = DebugStubs.SUCCESS
)
)
testMethod<IResponse>(request) {
assertEquals(ResponseResult.SUCCESS, it.result)
}
}
@Test
fun deleteStub() {
val request = ChatDeleteRequest(
chatId = UUID.randomUUID().toString(),
debug = Debug(
mode = DebugMode.STUB,
stub = DebugStubs.SUCCESS
)
)
testMethod<IResponse>(request) {
assertEquals(ResponseResult.SUCCESS, it.result)
}
}
@Test
fun searchStub() {
val request = ChatSearchRequest(
criteria = ChatSearchRequestAllOfCriteria(
title = "Chat search title",
type = ChatSearchRequestAllOfCriteria.Type.CHANNEL,
mode = ChatSearchRequestAllOfCriteria.Mode.WORK,
),
debug = Debug(
mode = DebugMode.STUB,
stub = DebugStubs.SUCCESS
)
)
testMethod<IResponse>(request) {
assertEquals(ResponseResult.SUCCESS, it.result)
}
}
private inline fun <reified T> testMethod(
request: IRequest,
crossinline assertBlock: (T) -> Unit
) = testApplication {
application { module(MessengerAppSettingsData(corSettings = MessengerCorSettings())) }
val client = createClient {
install(WebSockets) {
contentConverter = JacksonWebsocketContentConverter()
}
}
client.webSocket("/v1/ws") {
withTimeout(3000) {
val response = receiveDeserialized<IResponse>() as T
assertIs<ChatInitResponse>(response)
}
sendSerialized(request)
withTimeout(3000) {
val response = receiveDeserialized<IResponse>() as T
assertBlock(response)
}
}
}
}

View File

@ -0,0 +1,11 @@
plugins {
id("build-jvm")
}
dependencies {
implementation(kotlin("stdlib"))
implementation(project(":ok-messenger-common"))
implementation(project(":ok-messenger-stubs"))
testImplementation(kotlin("test-junit"))
}

View File

@ -0,0 +1,17 @@
package ru.otus.messenger.biz
import ru.otus.messenger.common.MessengerContext
import ru.otus.messenger.common.MessengerCorSettings
import ru.otus.messenger.common.models.ChatMode
import ru.otus.messenger.common.models.ChatState
import ru.otus.messenger.common.models.ChatType
import ru.otus.messenger.stubs.MessengerChatStub
@Suppress("unused", "RedundantSuspendModifier")
class MessengerProcessor(val corSettings: MessengerCorSettings) {
suspend fun exec(ctx: MessengerContext) {
ctx.chatResponse = MessengerChatStub.get()
ctx.chatsResponse = MessengerChatStub.prepareSearchList("New chat", ChatType.GROUP, ChatMode.PERSONAL).toMutableList()
ctx.state = ChatState.RUNNING
}
}

View File

@ -15,6 +15,7 @@ dependencies {
implementation(kotlin("stdlib"))
implementation(libs.kotlin.datetime)
implementation(libs.kotlinx.serialization.json)
api("ru.otus.messenger.libs:ok-messenger-lib-logging")
testImplementation(kotlin("test-junit"))
}

View File

@ -3,19 +3,21 @@ package ru.otus.messenger.common
import kotlinx.datetime.Instant
import ru.otus.messenger.common.models.*
import ru.otus.messenger.common.stubs.Stubs
import ru.otus.messenger.common.ws.IMessengerWsSession
data class ChatContext(
data class MessengerContext(
var command: ChatCommand = ChatCommand.NONE,
var state: ChatState = ChatState.NONE,
val errors: MutableList<ChatError> = mutableListOf(),
var workMode: WorkMode = WorkMode.PROD,
var stubCase: Stubs = Stubs.NONE,
var wsSession: IMessengerWsSession = IMessengerWsSession.NONE,
var requestId: RequestId = RequestId.NONE,
var timeStart: Instant = Instant.NONE,
var chatRequest: MessengerChat = MessengerChat(),
var chatFilterRequest: ChatSearchFilter = ChatSearchFilter(),
var chatFilterRequest: ChatSearchFilter = ChatSearchFilter.NONE,
var chatResponse: MessengerChat = MessengerChat(),
var chatsResponse: MutableList<MessengerChat> = mutableListOf(),

View File

@ -0,0 +1,13 @@
package ru.otus.messenger.common
import ru.otus.messenger.common.ws.IMessengerWsSessionRepo
import ru.otus.messenger.logging.common.LoggerProvider
data class MessengerCorSettings(
val loggerProvider: LoggerProvider = LoggerProvider(),
val wsSessions: IMessengerWsSessionRepo = IMessengerWsSessionRepo.NONE,
) {
companion object {
val NONE = MessengerCorSettings()
}
}

View File

@ -0,0 +1,15 @@
package ru.otus.messenger.common.helpers
import ru.otus.messenger.common.models.ChatError
fun Throwable.asMessengerError(
code: String = "unknown",
group: String = "exceptions",
message: String = this.message ?: "",
) = ChatError(
code = code,
group = group,
field = "",
message = message,
exception = this,
)

View File

@ -7,4 +7,6 @@ enum class ChatCommand {
DELETE,
SEARCH,
UPDATE,
INIT,
FINISH,
}

View File

@ -1,9 +1,12 @@
package ru.otus.messenger.common.models
import ru.otus.messenger.logging.common.LogLevel
data class ChatError(
val code: String = "",
val group: String = "",
val field: String = "",
val message: String = "",
val level: LogLevel = LogLevel.ERROR,
val exception: Throwable? = null,
)

View File

@ -1,7 +1,30 @@
package ru.otus.messenger.common.models
data class ChatSearchFilter(
var searchString: String = "",
var searchFields: List<SearchField> = emptyList(),
var ownerId: ChatOwnerId = ChatOwnerId.NONE,
var type: ChatType = ChatType.NONE,
)
var mode: ChatMode = ChatMode.NONE,
) {
interface SearchField {
val fieldName: String
val action: SearchAction
}
enum class SearchAction {
CONTAINS,
EQUALS,
MORE,
LESS
}
data class StringSearchField(
override val fieldName: String,
override val action: SearchAction = SearchAction.CONTAINS,
val stringValue: String,
) : SearchField
companion object {
val NONE = ChatSearchFilter()
}
}

View File

@ -0,0 +1,12 @@
package ru.otus.messenger.common.ws
interface IMessengerWsSession {
suspend fun <T> send(obj: T)
companion object {
val NONE = object : IMessengerWsSession {
override suspend fun <T> send(obj: T) {
}
}
}
}

View File

@ -0,0 +1,17 @@
package ru.otus.messenger.common.ws
interface IMessengerWsSessionRepo {
fun add(session: IMessengerWsSession)
fun clearAll()
fun remove(session: IMessengerWsSession)
suspend fun <K> sendAll(obj: K)
companion object {
val NONE = object : IMessengerWsSessionRepo {
override fun add(session: IMessengerWsSession) {}
override fun clearAll() {}
override fun remove(session: IMessengerWsSession) {}
override suspend fun <K> sendAll(obj: K) {}
}
}
}

View File

@ -0,0 +1,12 @@
plugins {
id("build-jvm")
}
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.kotlin.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(project(":ok-messenger-common"))
testImplementation(kotlin("test-junit"))
}

View File

@ -0,0 +1,22 @@
package ru.otus.messenger.stubs
import ru.otus.messenger.common.models.ChatMode
import ru.otus.messenger.common.models.ChatType
import ru.otus.messenger.common.models.MessengerChat
import ru.otus.messenger.stubs.MessengerChatStubSample.CHAT_SAMPLE_1
import ru.otus.messenger.stubs.MessengerChatStubSample.CHAT_SAMPLE_2
object MessengerChatStub {
fun get(): MessengerChat = CHAT_SAMPLE_1.copy()
fun prepareResult(block: MessengerChat.() -> Unit): MessengerChat = get().apply(block)
fun prepareSearchList(
chatTitle: String,
chatType: ChatType,
chatMode: ChatMode,
) = listOf(
CHAT_SAMPLE_1,
CHAT_SAMPLE_2
).filter { it.title == chatTitle && it.type == chatType && it.mode == chatMode }
}

View File

@ -0,0 +1,56 @@
package ru.otus.messenger.stubs
import java.util.UUID
import kotlin.time.Duration.Companion.hours
import kotlinx.datetime.Instant
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import ru.otus.messenger.common.models.*
object MessengerChatStubSample {
val chatId = UUID.randomUUID().toString()
val chatOwnerId = UUID.randomUUID().toString()
val participants = MutableList(3) { ChatUserId(UUID.randomUUID().toString()) }
private val timestamp = Instant.parse("2025-01-31T01:30:00.000-05:00")
val CHAT_SAMPLE_1: MessengerChat
get() = MessengerChat(
id = ChatId(chatId),
title = "New chat",
description = "New chat description",
type = ChatType.GROUP,
mode = ChatMode.PERSONAL,
ownerId = ChatOwnerId(chatOwnerId),
participants = (participants + ChatUserId(chatOwnerId)).toMutableSet(),
createdAt = timestamp,
updatedAt = timestamp.plus(10.hours),
isArchived = ChatArchiveFlag(false),
metadata = ChatMetadata(
buildJsonObject {
put("sampleId", "uuid4")
put("testParam", "test")
}
)
)
val CHAT_SAMPLE_2: MessengerChat
get() = MessengerChat(
id = ChatId(chatId),
title = "New chat",
description = "New chat description",
type = ChatType.GROUP,
mode = ChatMode.WORK,
ownerId = ChatOwnerId(chatOwnerId),
participants = (participants + ChatUserId(chatOwnerId)).toMutableSet(),
createdAt = timestamp.plus(100.hours),
updatedAt = timestamp.plus(101.hours),
isArchived = ChatArchiveFlag(false),
metadata = ChatMetadata(
buildJsonObject {
put("sampleId", "uuid5")
put("testParam", "test")
put("organization", "BlancLabs")
}
)
)
}

View File

@ -23,6 +23,10 @@ rootProject.name = "ok-messenger-be"
//enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
include(":ok-messenger-api-log-v1")
include(":ok-messenger-api-v1")
include(":ok-messenger-api-v1-mappers")
include(":ok-messenger-common")
include(":ok-messenger-stubs")
include(":ok-messenger-app")
include(":ok-messenger-biz")