diff --git a/ai-mocks-a2a/src/commonMain/kotlin/me/kpavlov/aimocks/a2a/AgentCardBuildingStep.kt b/ai-mocks-a2a/src/commonMain/kotlin/me/kpavlov/aimocks/a2a/AgentCardBuildingStep.kt index 63b01616..c3fad475 100644 --- a/ai-mocks-a2a/src/commonMain/kotlin/me/kpavlov/aimocks/a2a/AgentCardBuildingStep.kt +++ b/ai-mocks-a2a/src/commonMain/kotlin/me/kpavlov/aimocks/a2a/AgentCardBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.a2a +import io.ktor.http.ContentType import me.kpavlov.aimocks.core.AbstractBuildingStep import me.kpavlov.mokksy.BuildingStep import me.kpavlov.mokksy.MokksyServer @@ -13,6 +14,7 @@ public class AgentCardBuildingStep( val responseDefinition = this.build() val responseSpecification = AgentCardResponseSpecification(responseDefinition) block.invoke(responseSpecification) + contentType = ContentType.Application.Json body = requireNotNull(responseSpecification.card) { "Card must be defined" } } } diff --git a/ai-mocks-anthropic/src/commonMain/kotlin/me/kpavlov/aimocks/anthropic/AnthropicBuildingStep.kt b/ai-mocks-anthropic/src/commonMain/kotlin/me/kpavlov/aimocks/anthropic/AnthropicBuildingStep.kt index 043bf04d..d10553b7 100644 --- a/ai-mocks-anthropic/src/commonMain/kotlin/me/kpavlov/aimocks/anthropic/AnthropicBuildingStep.kt +++ b/ai-mocks-anthropic/src/commonMain/kotlin/me/kpavlov/aimocks/anthropic/AnthropicBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.anthropic +import io.ktor.http.ContentType import io.ktor.sse.TypedServerSentEvent import io.ktor.utils.io.InternalAPI import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -40,7 +41,7 @@ public class AnthropicBuildingStep( val stopReason = chatResponseSpecification.stopReason val completionTokens = LongRange(1, 10).random() delay = chatResponseSpecification.delay - + contentType = ContentType.Application.Json headers += "x-request-id" to randomIdString("req_") body = Message( diff --git a/ai-mocks-gemini/build.gradle.kts b/ai-mocks-gemini/build.gradle.kts index f18acac3..0e799cfe 100644 --- a/ai-mocks-gemini/build.gradle.kts +++ b/ai-mocks-gemini/build.gradle.kts @@ -17,6 +17,7 @@ kotlin { commonMain { dependencies { api(libs.ktor.serialization.kotlinx.json) + api(libs.ktor.sse) api(project(":ai-mocks-core")) api(project.dependencies.platform(libs.ktor.bom)) } diff --git a/ai-mocks-gemini/src/commonMain/kotlin/me/kpavlov/aimocks/gemini/content/GeminiContentBuildingStep.kt b/ai-mocks-gemini/src/commonMain/kotlin/me/kpavlov/aimocks/gemini/content/GeminiContentBuildingStep.kt index 67d56e39..67e9debb 100644 --- a/ai-mocks-gemini/src/commonMain/kotlin/me/kpavlov/aimocks/gemini/content/GeminiContentBuildingStep.kt +++ b/ai-mocks-gemini/src/commonMain/kotlin/me/kpavlov/aimocks/gemini/content/GeminiContentBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.gemini.content +import io.ktor.http.ContentType import me.kpavlov.aimocks.core.AbstractBuildingStep import me.kpavlov.aimocks.gemini.GenerateContentRequest import me.kpavlov.mokksy.BuildingStep @@ -35,7 +36,7 @@ public class GeminiContentBuildingStep( block.invoke(chatResponseSpecification) val assistantContent = chatResponseSpecification.content delay = chatResponseSpecification.delay - + contentType = ContentType.Application.Json body = generateContentResponse( assistantContent = assistantContent, diff --git a/ai-mocks-gemini/src/commonMain/kotlin/me/kpavlov/aimocks/gemini/content/GeminiStreamingContentBuildingStep.kt b/ai-mocks-gemini/src/commonMain/kotlin/me/kpavlov/aimocks/gemini/content/GeminiStreamingContentBuildingStep.kt index d591eea0..a555fc4d 100644 --- a/ai-mocks-gemini/src/commonMain/kotlin/me/kpavlov/aimocks/gemini/content/GeminiStreamingContentBuildingStep.kt +++ b/ai-mocks-gemini/src/commonMain/kotlin/me/kpavlov/aimocks/gemini/content/GeminiStreamingContentBuildingStep.kt @@ -1,5 +1,7 @@ package me.kpavlov.aimocks.gemini.content +import io.ktor.sse.TypedServerSentEvent +import io.ktor.utils.io.InternalAPI import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.map @@ -84,22 +86,22 @@ public class GeminiStreamingContentBuildingStep( } } + @OptIn(InternalAPI::class) private fun encodeChunk( chunk: GenerateContentResponse, sse: Boolean, lastChunk: Boolean = false, ): String { - val json = - Json.encodeToString( - value = chunk, - serializer = GenerateContentResponse.serializer(), - ) return if (sse) { - "data: $json\r\n\r\n" + TypedServerSentEvent( + data = chunk, + ).toString { + Json.encodeToString(it) + } } else if (lastChunk) { - json + Json.encodeToString(value = chunk) } else { - "$json,\r\n" + "${Json.encodeToString(value = chunk)},\r\n" } } diff --git a/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/chat/OllamaChatBuildingStep.kt b/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/chat/OllamaChatBuildingStep.kt index 29c51952..ae7af3e7 100644 --- a/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/chat/OllamaChatBuildingStep.kt +++ b/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/chat/OllamaChatBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.ollama.chat +import io.ktor.http.ContentType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow @@ -55,6 +56,7 @@ public class OllamaChatBuildingStep( ) block.invoke(chatResponseSpecification) delay = chatResponseSpecification.delay + contentType = ContentType.Application.Json val promptEvalCount = nextInt(1, 200) val evalCount = nextInt(1, 500) diff --git a/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/embed/OllamaEmbedBuildingStep.kt b/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/embed/OllamaEmbedBuildingStep.kt index 61050ef7..71f6a550 100644 --- a/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/embed/OllamaEmbedBuildingStep.kt +++ b/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/embed/OllamaEmbedBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.ollama.embed +import io.ktor.http.ContentType import kotlinx.datetime.Clock import me.kpavlov.aimocks.core.AbstractBuildingStep import me.kpavlov.aimocks.core.EmbeddingUtils @@ -51,6 +52,7 @@ public class OllamaEmbedBuildingStep( ?: request.input.map { EmbeddingUtils.generateEmbedding(it) } val modelName = embedResponseSpecification.model ?: request.model delay = embedResponseSpecification.delay + contentType = ContentType.Application.Json @Suppress("MagicNumber") val promptEvalCount = nextInt(1, 200) diff --git a/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/generate/OllamaGenerateBuildingStep.kt b/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/generate/OllamaGenerateBuildingStep.kt index bc4ee22b..3331fa21 100644 --- a/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/generate/OllamaGenerateBuildingStep.kt +++ b/ai-mocks-ollama/src/commonMain/kotlin/me/kpavlov/aimocks/ollama/generate/OllamaGenerateBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.ollama.generate +import io.ktor.http.ContentType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow @@ -55,6 +56,7 @@ public class OllamaGenerateBuildingStep( val responseContent = generateResponseSpecification.responseContent val doneReason = generateResponseSpecification.doneReason delay = generateResponseSpecification.delay + contentType = ContentType.Application.Json val promptEvalCount = nextInt(1, 200) val evalCount = nextInt(1, 500) diff --git a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/completions/OpenaiChatCompletionsBuildingStep.kt b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/completions/OpenaiChatCompletionsBuildingStep.kt index 7a553155..6dc6efe9 100644 --- a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/completions/OpenaiChatCompletionsBuildingStep.kt +++ b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/completions/OpenaiChatCompletionsBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.openai.completions +import io.ktor.http.ContentType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow @@ -63,6 +64,7 @@ public class OpenaiChatCompletionsBuildingStep( val assistantContent = chatResponseSpecification.assistantContent val finishReason = chatResponseSpecification.finishReason delay = chatResponseSpecification.delay + contentType = ContentType.Application.Json val promptTokens = nextInt(1, 200) val completionTokens = nextInt(1, request.maxCompletionTokens ?: 500) diff --git a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/embeddings/OpenaiEmbedBuildingStep.kt b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/embeddings/OpenaiEmbedBuildingStep.kt index 4bd527bc..e177a109 100644 --- a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/embeddings/OpenaiEmbedBuildingStep.kt +++ b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/embeddings/OpenaiEmbedBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.openai.embeddings +import io.ktor.http.ContentType import me.kpavlov.aimocks.core.AbstractBuildingStep import me.kpavlov.aimocks.core.EmbeddingUtils import me.kpavlov.aimocks.openai.model.embeddings.CreateEmbeddingsRequest @@ -55,6 +56,7 @@ public class OpenaiEmbedBuildingStep( responseSpecification.embeddings ?: request.input.map { EmbeddingUtils.generateEmbedding(it) } delay = responseSpecification.delay + contentType = ContentType.Application.Json val promptTokens = nextInt(1, 100) val totalTokens = nextInt(promptTokens, promptTokens + 500) diff --git a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/moderation/OpenaiModerationBuildingStep.kt b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/moderation/OpenaiModerationBuildingStep.kt index a419be72..13fe0c21 100644 --- a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/moderation/OpenaiModerationBuildingStep.kt +++ b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/moderation/OpenaiModerationBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.openai.moderation +import io.ktor.http.ContentType import me.kpavlov.aimocks.core.AbstractBuildingStep import me.kpavlov.aimocks.openai.model.moderation.CreateModerationRequest import me.kpavlov.aimocks.openai.model.moderation.Moderation @@ -30,6 +31,7 @@ public class OpenaiModerationBuildingStep( val spec = OpenaiModerationResponseSpecification(responseDefinition) block.invoke(spec) delay = spec.delay + contentType = ContentType.Application.Json val id = spec.id ?: "modr-${Integer.toHexString(counter.addAndGet(1))}" val createdModel = spec.model val result: ModerationResult = spec.toResult() diff --git a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/responses/OpenaiResponsesBuildingStep.kt b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/responses/OpenaiResponsesBuildingStep.kt index a8a40dea..7c138f7f 100644 --- a/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/responses/OpenaiResponsesBuildingStep.kt +++ b/ai-mocks-openai/src/commonMain/kotlin/me/kpavlov/aimocks/openai/responses/OpenaiResponsesBuildingStep.kt @@ -1,5 +1,6 @@ package me.kpavlov.aimocks.openai.responses +import io.ktor.http.ContentType import me.kpavlov.aimocks.core.AbstractBuildingStep import me.kpavlov.aimocks.openai.model.OutputContent import me.kpavlov.aimocks.openai.model.OutputMessage @@ -43,6 +44,7 @@ public class OpenaiResponsesBuildingStep( block.invoke(chatResponseSpecification) val assistantContent = chatResponseSpecification.assistantContent delay = chatResponseSpecification.delay + contentType = ContentType.Application.Json val inputTokens = Random.Default.nextInt(1, 200) val outputTokens = Random.Default.nextInt(1, request.maxOutputTokens ?: 1500) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c427ee13..6756cb8d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,7 @@ ktor-server-core = { module = "io.ktor:ktor-server-core" } ktor-server-double-receive = { module = "io.ktor:ktor-server-double-receive" } ktor-server-netty = { module = "io.ktor:ktor-server-netty" } ktor-server-sse = { module = "io.ktor:ktor-server-sse" } +ktor-sse = { module = "io.ktor:ktor-sse" } langchain4j-anthropic = { group = "dev.langchain4j", name = "langchain4j-anthropic" } langchain4j-bom = { group = "dev.langchain4j", name = "langchain4j-bom", version.ref = "langchain4j" } langchain4j-gemini = { group = "dev.langchain4j", name = "langchain4j-google-ai-gemini" } diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/BuildingStep.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/BuildingStep.kt index f0f96355..7b5994c5 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/BuildingStep.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/BuildingStep.kt @@ -5,6 +5,7 @@ import io.ktor.sse.ServerSentEventMetadata import me.kpavlov.mokksy.request.RequestSpecification import me.kpavlov.mokksy.response.ResponseDefinitionBuilder import me.kpavlov.mokksy.response.StreamingResponseDefinitionBuilder +import me.kpavlov.mokksy.utils.logger.HttpFormatter import java.io.IOException import kotlin.reflect.KClass @@ -25,6 +26,7 @@ public class BuildingStep

internal constructor( private val configuration: StubConfiguration, private val requestSpecification: RequestSpecification

, private val registerStub: (Stub<*, *>) -> Unit, + private val formatter: HttpFormatter, ) { /** * @param P The type of the request payload. @@ -37,11 +39,13 @@ public class BuildingStep

internal constructor( name: String?, requestSpecification: RequestSpecification

, registerStub: (Stub<*, *>) -> Unit, + formatter: HttpFormatter, ) : this( requestType = requestType, configuration = StubConfiguration(name), requestSpecification = requestSpecification, registerStub = registerStub, + formatter = formatter, ) /** @@ -62,8 +66,10 @@ public class BuildingStep

internal constructor( val req = CapturedRequest(call.request, requestType) @SuppressWarnings("TooGenericExceptionCaught") try { - ResponseDefinitionBuilder(request = req) - .apply(block) + ResponseDefinitionBuilder( + request = req, + formatter = formatter, + ).apply(block) .build() } catch (e: Exception) { if (e as? IOException == null) { @@ -91,15 +97,19 @@ public class BuildingStep

internal constructor( * @param block A lambda function applied to a [me.kpavlov.mokksy.response.StreamingResponseDefinitionBuilder], * used to configure the streaming response definition. */ - public infix fun respondsWithStream(block: StreamingResponseDefinitionBuilder.() -> Unit) { + public infix fun respondsWithStream( + block: StreamingResponseDefinitionBuilder.() -> Unit, + ) { val stub = Stub( configuration = configuration, requestSpecification = requestSpecification, ) { call -> val req = CapturedRequest(call.request, requestType) - StreamingResponseDefinitionBuilder(request = req) - .apply(block) + StreamingResponseDefinitionBuilder( + request = req, + formatter = formatter, + ).apply(block) .build() } diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/MokksyServer.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/MokksyServer.kt index a95a7d89..a37851d0 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/MokksyServer.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/MokksyServer.kt @@ -197,6 +197,7 @@ public open class MokksyServer requestSpecification = requestSpec, registerStub = this::registerStub, requestType = requestType, + formatter = httpFormatter, ) } diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/AbstractResponseDefinition.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/AbstractResponseDefinition.kt index fd2ea903..9737dbeb 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/AbstractResponseDefinition.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/AbstractResponseDefinition.kt @@ -9,21 +9,22 @@ import kotlin.time.Duration internal typealias ResponseDefinitionSupplier = ( ApplicationCall, ) -> AbstractResponseDefinition - /** * Represents the base definition of an HTTP response in a mapping between a request and its corresponding response. * Provides the required attributes and behavior for configuring HTTP responses, including status code, headers, * and content type. This class serves as the foundation for more specialized response definitions. * * @param T The type of the response data. - * @property contentType The MIME type of the response content. Defaults to `null`. + * @property contentType The MIME type of the response content. * @property httpStatusCode The HTTP status code of the response as Int, defaulting to 200. * @property httpStatus The HTTP status code of the response. Defaults to [HttpStatusCode.OK]. * @property headers A lambda function for configuring the response headers. Defaults to `null`. * @property headerList A list of header key-value pairs to populate the response headers. Defaults to an empty list. + * @property delay A delay applied before sending the response. Defaults to [Duration.ZERO]. + * @property responseBody The optional response payload associated with this definition. */ public abstract class AbstractResponseDefinition( - public val contentType: ContentType? = null, + public val contentType: ContentType, public val httpStatusCode: Int = 200, public val httpStatus: HttpStatusCode = HttpStatusCode.fromValue(httpStatusCode), public val headers: (ResponseHeaders.() -> Unit)? = null, diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/ResponseDefinition.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/ResponseDefinition.kt index edbeafc6..67e280aa 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/ResponseDefinition.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/ResponseDefinition.kt @@ -4,10 +4,12 @@ import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.application.log +import io.ktor.server.request.httpVersion import io.ktor.server.response.ResponseHeaders import io.ktor.server.response.respond import io.ktor.util.cio.ChannelWriteException import kotlinx.coroutines.delay +import me.kpavlov.mokksy.utils.logger.HttpFormatter import kotlin.time.Duration /** @@ -33,6 +35,7 @@ public open class ResponseDefinition( headers: (ResponseHeaders.() -> Unit)? = null, headerList: List> = emptyList>(), delay: Duration, + private val formatter: HttpFormatter, ) : AbstractResponseDefinition( contentType = contentType, httpStatusCode = httpStatusCode, @@ -48,13 +51,25 @@ public open class ResponseDefinition( if (this.delay.isPositive()) { delay(delay) } + val effectiveBody = responseBody ?: body if (verbose) { - call.application.log.debug("Sending {}: {}", httpStatus, body) + call.application.log.debug( + "Sending:\n---\n${ + formatter.formatResponse( + httpVersion = call.request.httpVersion, + headers = call.response.headers, + contentType = this.contentType, + status = httpStatus, + body = effectiveBody?.toString(), + ) + }---\n", + ) } try { + val payload: Any = effectiveBody ?: "" call.respond( status = httpStatus, - message = body ?: "" as Any, + message = payload, ) } catch (e: ChannelWriteException) { // We can't do anything about it diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/ResponseDefinitionBuilders.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/ResponseDefinitionBuilders.kt index e125b460..6b9657aa 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/ResponseDefinitionBuilders.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/ResponseDefinitionBuilders.kt @@ -5,7 +5,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.server.response.ResponseHeaders import kotlinx.coroutines.flow.Flow import me.kpavlov.mokksy.CapturedRequest -import java.util.Collections +import me.kpavlov.mokksy.utils.logger.HttpFormatter import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -50,6 +50,7 @@ public abstract class AbstractResponseDefinitionBuilder( } public fun httpStatus(status: Int) { + this.httpStatusCode = status this.httpStatus = HttpStatusCode.fromValue(status) } @@ -66,8 +67,7 @@ public abstract class AbstractResponseDefinitionBuilder( * * @param P The type of the request body. * @param T The type of the response body. - * @property contentType Optional MIME type of the response. - * Defaults to `ContentType.Application.Json` if not specified. + * @property contentType Optional MIME type of the response. Defaults to `null` if not specified. * @property body The body of the response. Can be null. * @property httpStatusCode The HTTP status code of the response as Int, defaulting to 200. * @property httpStatus The HTTP status code of the response, defaulting to [HttpStatusCode.OK]. @@ -83,6 +83,7 @@ public open class ResponseDefinitionBuilder

( httpStatusCode: Int = 200, httpStatus: HttpStatusCode = HttpStatusCode.fromValue(httpStatusCode), headers: MutableList> = mutableListOf(), + private val formatter: HttpFormatter, ) : AbstractResponseDefinitionBuilder( httpStatusCode = httpStatusCode, httpStatus = httpStatus, @@ -95,8 +96,9 @@ public open class ResponseDefinitionBuilder

( httpStatusCode = httpStatusCode, httpStatus = httpStatus, headers = headersLambda, - headerList = Collections.unmodifiableList(headers), + headerList = headers.toList(), delay = delay, + formatter = formatter, ) } @@ -120,7 +122,12 @@ public open class StreamingResponseDefinitionBuilder

( public var delayBetweenChunks: Duration = Duration.ZERO, httpStatus: HttpStatusCode = HttpStatusCode.OK, headers: MutableList> = mutableListOf(), -) : AbstractResponseDefinitionBuilder(httpStatus = httpStatus, headers = headers) { + public val chunkContentType: ContentType? = null, + private val formatter: HttpFormatter, +) : AbstractResponseDefinitionBuilder( + httpStatus = httpStatus, + headers = headers, + ) { /** * Builds an instance of `StreamResponseDefinition`. * @@ -139,8 +146,10 @@ public open class StreamingResponseDefinitionBuilder

( chunks = chunks.toList(), httpStatus = httpStatus, headers = headersLambda, - headerList = Collections.unmodifiableList(headers), + headerList = headers.toList(), delayBetweenChunks = delayBetweenChunks, delay = delay, + formatter = formatter, + chunkContentType = chunkContentType, ) } diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/SseStreamResponseDefinition.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/SseStreamResponseDefinition.kt index f4775d3d..38eda338 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/SseStreamResponseDefinition.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/SseStreamResponseDefinition.kt @@ -1,7 +1,7 @@ package me.kpavlov.mokksy.response +import io.ktor.http.ContentType import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.application.log import io.ktor.server.response.header @@ -14,12 +14,20 @@ import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emptyFlow +import me.kpavlov.mokksy.utils.logger.HttpFormatter import kotlin.time.Duration public open class SseStreamResponseDefinition

( override val chunkFlow: Flow? = null, + chunkContentType: ContentType? = null, delay: Duration = Duration.ZERO, -) : StreamResponseDefinition(delay = delay) { + formatter: HttpFormatter, +) : StreamResponseDefinition( + chunkFlow = chunkFlow, + chunkContentType = chunkContentType, + delay = delay, + formatter = formatter, + ) { override suspend fun writeResponse( call: ApplicationCall, verbose: Boolean, @@ -35,7 +43,7 @@ public open class SseStreamResponseDefinition

( ).catch { call.application.log.error("Error while sending SSE events", it) } .collect { if (verbose) { - call.application.log.debug("Sending {}: {}", httpStatus, it) + call.application.log.debug("Sending $httpStatus: $it") } send(it) } @@ -57,7 +65,7 @@ public open class SseStreamResponseDefinition

( call.response.header(HttpHeaders.CacheControl, "no-store") call.response.header(HttpHeaders.Connection, "keep-alive") call.response.header("X-Accel-Buffering", "no") - call.response.status(HttpStatusCode.OK) + call.response.status(httpStatus) call.respond(content) } } diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/StreamResponseDefinition.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/StreamResponseDefinition.kt index a75cdbcb..14062b32 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/StreamResponseDefinition.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/response/StreamResponseDefinition.kt @@ -5,10 +5,13 @@ import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.withCharset import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.log +import io.ktor.server.request.httpVersion import io.ktor.server.response.ResponseHeaders import io.ktor.server.response.cacheControl import io.ktor.server.response.respondBytesWriter import io.ktor.server.sse.ServerSSESession +import io.ktor.util.logging.Logger import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.writeStringUtf8 import kotlinx.coroutines.channels.BufferOverflow @@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.yield +import me.kpavlov.mokksy.utils.logger.HttpFormatter import kotlin.time.Duration internal const val SEND_BUFFER_CAPACITY = 256 @@ -48,11 +52,13 @@ public open class StreamResponseDefinition( public val chunks: List? = null, public val delayBetweenChunks: Duration = Duration.ZERO, contentType: ContentType = ContentType.Text.EventStream.withCharset(Charsets.UTF_8), + private val chunkContentType: ContentType? = null, httpStatusCode: Int = 200, httpStatus: HttpStatusCode = HttpStatusCode.fromValue(httpStatusCode), headers: (ResponseHeaders.() -> Unit)? = null, headerList: List> = emptyList>(), delay: Duration, + private val formatter: HttpFormatter, ) : AbstractResponseDefinition( contentType = contentType, httpStatusCode = httpStatusCode, @@ -62,6 +68,7 @@ public open class StreamResponseDefinition( delay = delay, ) { internal suspend fun writeChunksFromFlow( + logger: Logger, writer: ByteWriteChannel, verbose: Boolean, ) { @@ -74,9 +81,15 @@ public open class StreamResponseDefinition( ?.buffer( capacity = SEND_BUFFER_CAPACITY, onBufferOverflow = BufferOverflow.SUSPEND, - )?.catch { print("Error while sending chunks: $it") } + )?.catch { logger.warn("Error while sending chunks: $it") } ?.collect { - writeChunk(writer, it, verbose) + writeChunk( + writer = writer, + value = it, + verbose = verbose, + logger = logger, + chunkContentTypeOverride = chunkContentType, + ) } } @@ -84,12 +97,29 @@ public open class StreamResponseDefinition( writer: ByteWriteChannel, value: T, verbose: Boolean, + logger: Logger, + chunkContentTypeOverride: ContentType? = null, serialize: (T) -> String = { "$it" }, ) { + val serializedValue = serialize(value) if (verbose) { - print("$value") + val type = + chunkContentTypeOverride + ?: chunkContentType + ?: when (value) { + is CharSequence -> ContentType.Text.Plain + else -> ContentType.Application.Json + } + logger.debug( + "Writing chunk:\n ${ + formatter.formatResponseChunk( + chunk = serializedValue, + contentType = type, + ) + }", + ) } - writer.writeStringUtf8(serialize(value)) + writer.writeStringUtf8(serializedValue) writer.flush() yield() if (delayBetweenChunks.isPositive()) { @@ -124,12 +154,20 @@ public open class StreamResponseDefinition( internal suspend fun writeChunksFromList( writer: ByteWriteChannel, verbose: Boolean, + logger: Logger, + chunkContentType: ContentType?, ) { if (this.delay.isPositive()) { delay(delay) } chunks?.forEach { - writeChunk(writer, it, verbose) + writeChunk( + writer = writer, + value = it, + verbose = verbose, + logger = logger, + chunkContentTypeOverride = chunkContentType, + ) } } @@ -137,6 +175,11 @@ public open class StreamResponseDefinition( call: ApplicationCall, verbose: Boolean, ) { + // Apply configured headers + headers?.invoke(call.response.headers) + for ((name, value) in headerList) { + call.response.headers.append(name, value) + } when { chunkFlow != null -> { call.response.cacheControl(CacheControl.NoCache(null)) @@ -144,7 +187,22 @@ public open class StreamResponseDefinition( status = this.httpStatus, contentType = this.contentType, ) { - writeChunksFromFlow(writer = this, verbose) + if (verbose) { + call.application.log.debug( + "Sending:\n---\n${ + formatter.formatResponseHeader( + httpVersion = call.request.httpVersion, + headers = call.response.headers, + status = httpStatus, + ) + }", + ) + } + writeChunksFromFlow( + writer = this, + verbose = verbose, + logger = call.application.log, + ) } } @@ -154,7 +212,12 @@ public open class StreamResponseDefinition( status = this.httpStatus, contentType = this.contentType, ) { - writeChunksFromList(this, verbose) + writeChunksFromList( + writer = this, + verbose = verbose, + logger = call.application.log, + chunkContentType = chunkContentType, + ) } } } diff --git a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.kt b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.kt index 374520b6..46796692 100644 --- a/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.kt +++ b/mokksy/src/commonMain/kotlin/me/kpavlov/mokksy/utils/logger/HttpFormatter.kt @@ -2,10 +2,12 @@ package me.kpavlov.mokksy.utils.logger import io.ktor.http.ContentType import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode import io.ktor.server.request.contentType import io.ktor.server.request.httpMethod import io.ktor.server.request.receiveText import io.ktor.server.request.uri +import io.ktor.server.response.ResponseHeaders import io.ktor.server.routing.RoutingRequest import me.kpavlov.mokksy.utils.logger.Highlighting.highlightBody @@ -109,6 +111,16 @@ public open class HttpFormatter( ) }" + public fun responseLine( + httpVersion: String, + status: HttpStatusCode, + ): String = + colorize( + "$httpVersion ${status.value} ${status.description}", + AnsiColor.STRONGER, + useColor, + ) + /** * Formats an HTTP header line with colorized header name and values. * @@ -131,7 +143,9 @@ public open class HttpFormatter( /** * Formats the HTTP request body, applying syntax highlighting if color output is enabled. * - * Returns an empty string if the body is null or blank. If color output is enabled, the body is highlighted according to its content type; otherwise, the raw body string is returned. + * Returns an empty string if the body is null or blank. + * If color output is enabled, the body is highlighted according to its content type; + * otherwise, the raw body string is returned. * * @param body The HTTP request body to format. * @param contentType The content type of the body, used for syntax highlighting. @@ -148,7 +162,8 @@ public open class HttpFormatter( /** * Formats an HTTP request into a colorized, multi-line string representation. * - * The output includes the request line, all headers, and the request body, with color highlighting applied according to the formatter's theme and color settings. + * The output includes the request line, all headers, and the request body, + * with color highlighting applied according to the formatter's theme and color settings. * * @param request The HTTP routing request to format. * @return A formatted string representing the full HTTP request. @@ -157,14 +172,47 @@ public open class HttpFormatter( val body = request.call.receiveText() return buildString { appendLine(requestLine(request.httpMethod, request.uri)) - request.headers.entries().forEach { - appendLine(header(it.key, it.value)) + request.headers.entries().forEach { (key, value) -> + appendLine(header(key, value)) } appendLine() appendLine(formatBody(body, request.contentType())) } } + internal fun formatResponseHeader( + httpVersion: String, + status: HttpStatusCode, + headers: ResponseHeaders, + ): String = + buildString { + appendLine(responseLine(httpVersion, status)) + headers.allValues().entries().forEach { (key, value) -> + appendLine(header(key, value)) + } + } + + internal fun formatResponse( + httpVersion: String, + status: HttpStatusCode, + headers: ResponseHeaders, + body: String?, + contentType: ContentType, + ): String = + buildString { + append(formatResponseHeader(httpVersion, status, headers)) + appendLine() + appendLine(formatBody(body = body, contentType = contentType)) + } + + internal fun formatResponseChunk( + chunk: String?, + contentType: ContentType = ContentType.Text.Plain, + ): String { + if (chunk.isNullOrBlank()) return "" + return if (useColor) highlightBody(chunk, contentType) else chunk + } + public data class ColorScheme( val path: AnsiColor, val headerName: AnsiColor, diff --git a/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/BuildingStepTest.kt b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/BuildingStepTest.kt index e8a9d712..d0f1f656 100644 --- a/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/BuildingStepTest.kt +++ b/mokksy/src/commonTest/kotlin/me/kpavlov/mokksy/BuildingStepTest.kt @@ -9,6 +9,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.slot import me.kpavlov.mokksy.request.RequestSpecification +import me.kpavlov.mokksy.utils.logger.HttpFormatter import java.util.UUID import kotlin.test.BeforeTest import kotlin.test.Test @@ -51,6 +52,7 @@ internal class BuildingStepTest { requestSpecification = request, registerStub = addStubCallback, requestType = Input::class, + formatter = HttpFormatter(), ) } diff --git a/mokksy/src/jvmMain/kotlin/me/kpavlov/mokksy/utils/Base64Urls.jvm.kt b/mokksy/src/jvmMain/kotlin/me/kpavlov/mokksy/utils/Base64Urls.jvm.kt index dc2fad5d..198c6b43 100644 --- a/mokksy/src/jvmMain/kotlin/me/kpavlov/mokksy/utils/Base64Urls.jvm.kt +++ b/mokksy/src/jvmMain/kotlin/me/kpavlov/mokksy/utils/Base64Urls.jvm.kt @@ -8,8 +8,9 @@ import java.nio.file.Files import java.nio.file.Path @JvmOverloads -public fun File.asBase64DataUrl(mimeType: MimeType = ContentType.defaultForFile(this).toString()): String = - this.readBytes().asBase64DataUrl(mimeType) +public fun File.asBase64DataUrl( + mimeType: MimeType = ContentType.defaultForFile(this).toString(), +): String = this.readBytes().asBase64DataUrl(mimeType) @JvmOverloads public fun Path.asBase64DataUrl(