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 .() -> Unit) {
+ public infix fun .() -> 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 (
headers: (ResponseHeaders.() -> Unit)? = null,
headerList: List (
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 (
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 (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 (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 (
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(