Skip to content

Commit

Permalink
logbook-ktor module
Browse files Browse the repository at this point in the history
  • Loading branch information
sokomishalov committed Aug 10, 2021
1 parent ee4ad1f commit 5c8d2cc
Show file tree
Hide file tree
Showing 16 changed files with 767 additions and 0 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Logbook is ready to use out of the box for most common setups. Even for uncommon
- OkHttp 2.x **or 3.x** (optional)
- Spring 4.x **or 5.x** (optional)
- Spring Boot 1.x **or 2.x** (optional)
- Ktor (optional)
- logstash-logback-encoder 5.x (optional)

## Installation
Expand Down Expand Up @@ -109,6 +110,10 @@ Alternatively, you can import our *bill of materials*...
<groupId>org.zalando</groupId>
<artifactId>logbook-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-ktor</artifactId>
</dependency>
<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-logstash</artifactId>
Expand Down Expand Up @@ -735,6 +740,30 @@ OkHttpClient client = new OkHttpClient.Builder()
.build();
```
### Ktor
The `logbook-ktor` module contains:
A `LogbookClient` to be used with an `HttpClient`:
```kotlin
private val client = HttpClient(CIO) {
install(LogbookClient) {
logbook = logbook
}
}
```
A `LogbookServer` to be used with an `Application`:
```kotlin
private val server = embeddedServer(CIO) {
install(LogbookServer) {
logbook = logbook
}
}
```
### Spring
The `logbook-spring` module contains a `ClientHttpRequestInterceptor` to use with `RestTemplate`:
Expand Down
5 changes: 5 additions & 0 deletions logbook-bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
<artifactId>logbook-servlet</artifactId>
<version>2.12.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-ktor</artifactId>
<version>2.12.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-spring-boot-autoconfigure</artifactId>
Expand Down
127 changes: 127 additions & 0 deletions logbook-ktor/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.zalando</groupId>
<artifactId>logbook-parent</artifactId>
<version>2.12.0-SNAPSHOT</version>
</parent>

<artifactId>logbook-ktor</artifactId>

<properties>
<kotlin.version>1.5.21</kotlin.version>
<ktor.version>1.6.2</ktor.version>
</properties>

<dependencies>
<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-api</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-client-core-jvm</artifactId>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-core</artifactId>
</dependency>
<!-- testing -->
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-client-cio-jvm</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-client-logging-jvm</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-cio</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-nop</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-bom</artifactId>
<version>${ktor.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-bom</artifactId>
<version>${kotlin.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<configuration>
<jvmTarget>${java.version}</jvmTarget>
<args>
<arg>-Xopt-in=kotlin.RequiresOptIn</arg>
</args>
</configuration>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.zalando.logbook.ktor

import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.util.*
import org.zalando.logbook.HttpHeaders
import org.zalando.logbook.HttpRequest
import org.zalando.logbook.Origin
import java.nio.charset.Charset
import java.util.*
import java.util.concurrent.atomic.AtomicReference
import kotlin.text.Charsets.UTF_8


internal class ClientRequest(
private val request: HttpRequestBuilder
) : HttpRequest {
private val state: AtomicReference<State> = AtomicReference(State.Unbuffered)

override fun getProtocolVersion(): String = "HTTP/1.1" // fixme extract the real one
override fun getOrigin(): Origin = Origin.LOCAL
override fun getHeaders(): HttpHeaders = HttpHeaders.of(request.headers.build().toMap())
override fun getContentType(): String? = request.contentType()?.contentType
override fun getCharset(): Charset = request.charset() ?: UTF_8
override fun getRemote(): String = "localhost"
override fun getMethod(): String = request.method.value
override fun getScheme(): String = request.url.protocol.name
override fun getHost(): String = request.host
override fun getPort(): Optional<Int> = Optional.of(request.port)
override fun getPath(): String = request.url.encodedPath
override fun getQuery(): String = request.url.buildString().substringAfter("?", "")
override fun getRequestUri(): String = request.url.buildString()
override fun withBody(): HttpRequest = apply { state.updateAndGet { it.with() } }
override fun withoutBody(): HttpRequest = apply { state.updateAndGet { it.without() } }
override fun getBody(): ByteArray = state.get().body
internal fun buffer(bytes: ByteArray): State = state.updateAndGet { it.buffer(bytes) }
internal fun shouldBuffer(): Boolean = state.get() is State.Offering
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.zalando.logbook.ktor

import io.ktor.http.*
import io.ktor.util.*
import org.zalando.logbook.HttpHeaders
import org.zalando.logbook.HttpResponse
import org.zalando.logbook.Origin
import java.nio.charset.Charset
import java.util.concurrent.atomic.AtomicReference
import kotlin.text.Charsets.UTF_8
import io.ktor.client.statement.HttpResponse as KtorResponse


internal class ClientResponse(
private val response: KtorResponse
) : HttpResponse {
private val state: AtomicReference<State> = AtomicReference(State.Unbuffered)

override fun getProtocolVersion(): String = response.version.toString()
override fun getOrigin(): Origin = Origin.REMOTE
override fun getHeaders(): HttpHeaders = HttpHeaders.of(response.headers.toMap())
override fun getContentType(): String? = response.contentType()?.contentType
override fun getCharset(): Charset = response.charset() ?: UTF_8
override fun getStatus(): Int = response.status.value
override fun withBody(): HttpResponse = apply { state.updateAndGet { it.with() } }
override fun withoutBody(): HttpResponse = apply { state.updateAndGet { it.without() } }
override fun getBody(): ByteArray = state.get().body
internal fun buffer(bytes: ByteArray) = state.updateAndGet { it.buffer(bytes) }
internal fun shouldBuffer(): Boolean = state.get() is State.Offering
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.zalando.logbook.ktor

import io.ktor.http.content.*
import io.ktor.util.*
import io.ktor.utils.io.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers

@JvmField
internal val EMPTY_BODY = ByteArray(0)

internal suspend fun OutgoingContent.readBytes(scope: CoroutineScope): ByteArray = runCatching {
when (this) {
is OutgoingContent.ByteArrayContent -> bytes()
is OutgoingContent.ReadChannelContent -> readFrom().toByteArray()
is OutgoingContent.WriteChannelContent -> scope.writer(Dispatchers.Unconfined) { writeTo(channel) }.channel.toByteArray()
else -> EMPTY_BODY
}
}.getOrElse {
EMPTY_BODY
}

internal suspend fun ByteReadChannel.readBytes(): ByteArray = runCatching {
toByteArray()
}.getOrElse {
EMPTY_BODY
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.zalando.logbook.ktor

import kotlin.RequiresOptIn.Level.WARNING
import kotlin.annotation.AnnotationRetention.BINARY
import kotlin.annotation.AnnotationTarget.*


/**
* This annotation marks the API is considered experimental and the behavior of such API may be changed or the API may be removed completely in any further release.
*
* Any usage of a declaration annotated with `@ExperimentalLogbookKtorApi` must be accepted either by
* annotating that usage with the [OptIn] annotation, e.g. `@OptIn(ExperimentalLogbookKtorApi::class)`,
* or by using the compiler argument `-Xopt-in=org.zalando.logbook.ktor.ExperimentalLogbookKtorApi`.
*/
@RequiresOptIn(level = WARNING)
@Retention(BINARY)
@Target(
CLASS,
ANNOTATION_CLASS,
PROPERTY,
FIELD,
LOCAL_VARIABLE,
VALUE_PARAMETER,
CONSTRUCTOR,
FUNCTION,
PROPERTY_GETTER,
PROPERTY_SETTER,
TYPEALIAS
)
annotation class ExperimentalLogbookKtorApi
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
@file:Suppress(
"BlockingMethodInNonBlockingContext"
)

package org.zalando.logbook.ktor

import io.ktor.client.*
import io.ktor.client.features.*
import io.ktor.client.features.observer.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.content.*
import io.ktor.util.*
import io.ktor.utils.io.*
import org.apiguardian.api.API
import org.apiguardian.api.API.Status.EXPERIMENTAL
import org.zalando.logbook.Logbook
import org.zalando.logbook.Logbook.ResponseProcessingStage


@API(status = EXPERIMENTAL)
@ExperimentalLogbookKtorApi
class LogbookClient(
val logbook: Logbook
) {

class Config {
var logbook: Logbook = Logbook.create()
}

companion object : HttpClientFeature<Config, LogbookClient> {
private val stageKey: AttributeKey<ResponseProcessingStage> = AttributeKey("Logbook.Stage")
override val key: AttributeKey<LogbookClient> = AttributeKey("LogbookFeature")
override fun prepare(block: Config.() -> Unit): LogbookClient = LogbookClient(Config().apply(block).logbook)

override fun install(feature: LogbookClient, scope: HttpClient) {
scope.sendPipeline.intercept(HttpSendPipeline.Monitoring) {
val request = ClientRequest(context)
val requestWritingStage = feature.logbook.process(request)
val body = context.body
if (request.shouldBuffer() && body is OutgoingContent) {
val content = body.readBytes(scope)
request.buffer(content)
}
val responseStage = requestWritingStage.write()
context.attributes.put(stageKey, responseStage)
proceed()
}

scope.receivePipeline.intercept(HttpReceivePipeline.After) {
val (loggingContent, responseContent) = it.content.split(it)

val responseProcessingStage = it.call.attributes.getOrNull(stageKey)
if (responseProcessingStage != null) {
val response = ClientResponse(it)
val responseWritingStage = responseProcessingStage.process(response)
if (response.shouldBuffer() && !loggingContent.isClosedForRead) {
val content = loggingContent.readBytes()
response.buffer(content)
}
responseWritingStage.write()
}

val newClientCall = context.wrapWithContent(responseContent)
proceedWith(newClientCall.response)
}
}
}
}
Loading

0 comments on commit 5c8d2cc

Please sign in to comment.