Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Feign is a declarative HTTP client library with a modular design:
- `hystrix/` - Circuit breaker integration
- `micrometer/`, `dropwizard-metrics4/5/` - Metrics integration
- `validation/`, `validation-jakarta/` - JSR-303 / Jakarta Bean Validation via the `MethodInterceptor` extension point
- `webflux/` - Non-blocking `AsyncClient` (`WebClientAsyncClient`) based on Spring `WebClient`
- `http-cache/` - Conditional revalidation (`ETag` / `Last-Modified` / `304 Not Modified`) via the `MethodInterceptor` extension point
- `jdk-json/` - Codec for the JDK Simple JSON API (`jdk.incubator.json`, JEP 540). It only builds if you use `-Pjdk28-modules` and a JDK 28 toolchain.

Expand Down
5 changes: 5 additions & 0 deletions feign-bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,11 @@
<artifactId>feign-vertx</artifactId>
<version>13.18-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-webflux</artifactId>
<version>13.18-SNAPSHOT</version>
</dependency>
</dependencies>
</dependencyManagement>

Expand Down
77 changes: 77 additions & 0 deletions kotlin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# feign-kotlin

Kotlin support for Feign, including coroutine (suspend function) clients.

## CoroutineFeign

`CoroutineFeign` builds clients whose `suspend` functions are executed asynchronously:

```kotlin
val api = CoroutineFeign.builder()
.client(AsyncClient.Default(Client.Default(null, null), Executors.newCachedThreadPool()))
.target(GitHubApi::class.java, "https://api.github.com")

val repositories = api.listRepositories("openfeign") // suspend fun
```

## Using suspend functions with the blocking builder

`CoroutineFeign` already resolves the declared Kotlin return type for `suspend`
functions. If you use the plain blocking `Feign.builder()` with an interface that
contains `suspend` functions, the JVM signature reports `java.lang.Object` as the
return type, and decoders would receive `Object` instead of the declared type
(see [#2019](https://github.com/OpenFeign/feign/issues/2019)).

Wrap your contract with `KotlinDelegatingContract` to fix the resolved type:

```kotlin
val api = Feign.builder()
.contract(KotlinDelegatingContract(Contract.Default()))
.decoder(JacksonDecoder())
.target(GitHubApi::class.java, "https://api.github.com")
```

## Truly non-blocking execution

By default `CoroutineFeign` wraps a blocking `Client` with
`DefaultAsyncClient`, which runs the blocking I/O on a cached thread pool. The
suspending call never blocks the caller, but a thread is still occupied per
in-flight request.

For fully non-blocking execution, plug in any `AsyncClient` implementation that
completes its `CompletableFuture` without an extra thread, e.g.
`feign-http2client` (`Http2Client`, Java 11+ `HttpClient`), `feign-hc5`
(`AsyncApacheHttp5Client`), `feign-vertx`, or `feign-webflux`
(`WebClientAsyncClient`, Spring `WebClient` on Reactor Netty):

```kotlin
val api = CoroutineFeign.builder<GitHubApi>()
.client(Http2Client() as AsyncClient<GitHubApi>) // or AsyncApacheHttp5Client()
.decoder(JacksonDecoder())
.target(GitHubApi::class.java, "https://api.github.com")
```

The HTTP request now runs on the async client's own I/O machinery, and response
decoding happens on the thread that completes the future rather than on a
dedicated pool thread. See `NonBlockingCoroutineFeignTest` for a runnable
example.

## Flow support

`CoroutineFeign` supports `suspend` functions returning `kotlinx.coroutines.flow.Flow<T>`.

The response body is decoded as a buffered `List<T>` (the same semantics applied to
`Flux` returns by the reactive module) and bridged back into a cold `Flow`, so the
`suspend` call still suspends until the full response arrives while the caller can
consume elements as a stream:

```kotlin
interface EventsApi {
@GET("/events")
suspend fun events(): Flow<Event>
}
```

Note that decoding is fully buffered on the server response, so this is not a true
streaming/SSE transport; see [#971](https://github.com/OpenFeign/feign/issues/971) for
the background discussion.
7 changes: 7 additions & 0 deletions kotlin/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
<version>${kotlinx.coroutines.version}</version>
</dependency>

<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>feign-java11</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
Expand Down
43 changes: 43 additions & 0 deletions kotlin/src/main/kotlin/feign/kotlin/KotlinDelegatingContract.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package feign.kotlin

import feign.Contract
import feign.MethodMetadata

/**
* Contract wrapper that corrects [MethodMetadata.returnType] for Kotlin `suspend` functions.
*
* On the JVM, a `suspend fun foo(): Bar` is compiled with return type `java.lang.Object`, so a
* plain [feign.Feign.builder] (blocking path) would hand `Object` to [feign.codec.Decoder] for
* such methods (see issue #2019). This contract re-resolves the return type via kotlin-reflect
* so that decoders receive the declared Kotlin return type.
*
* The [feign.kotlin.CoroutineFeign] path already resolves the correct type through
* [feign.KotlinMethodInfo]; this contract offers the same behavior to blocking clients.
*/
class KotlinDelegatingContract(private val delegate: Contract) : Contract {

override fun parseAndValidateMetadata(targetType: Class<*>): List<MethodMetadata> {
return delegate.parseAndValidateMetadata(targetType).map { metadata ->
val method = metadata.method()
if (method != null && method.isSuspend) {
method.kotlinMethodReturnType?.let { metadata.returnType(it) }
}
metadata
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package feign.kotlin

import feign.RequestLine
import feign.Response
import feign.codec.Decoder
import feign.http2client.Http2Client
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import java.lang.reflect.Type
import java.util.concurrent.atomic.AtomicReference

/**
* Demonstrates combining [CoroutineFeign] with a non-blocking [feign.AsyncClient]
* implementation: the request executes on the HTTP client's own executor and the
* caller thread is never parked waiting for I/O.
*/
class NonBlockingCoroutineFeignTest {

interface TestApi {
@RequestLine("GET /")
suspend fun get(): String
}

@Test
fun `suspend calls with Http2Client decode off the caller thread`() {
val server = MockWebServer()
server.start()
server.enqueue(MockResponse().setBody("\"hello\""))

val decodingThread = AtomicReference<Thread>()
@Suppress("UNCHECKED_CAST")
val api = CoroutineFeign.builder<TestApi>()
.client(Http2Client() as feign.AsyncClient<TestApi>)
.decoder(
object : Decoder {
private val delegate: Decoder = Decoder.Default()

override fun decode(response: Response, type: Type): Any {
decodingThread.set(Thread.currentThread())
return delegate.decode(response, type)
}
})
.target(TestApi::class.java, server.url("/").toString())

val callerThread = Thread.currentThread()
val body = runBlocking { api.get() }

assertThat(body).isEqualTo("\"hello\"")
// The response was decoded on the async client's completion thread,
// not on the thread that invoked the suspend function.
assertThat(decodingThread.get()).isNotSameAs(callerThread)

server.shutdown()
}
}
103 changes: 103 additions & 0 deletions kotlin/src/test/kotlin/feign/kotlin/SuspendDecoderTypeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package feign.kotlin

import feign.RequestLine
import feign.codec.Decoder
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.lang.reflect.Type

/**
* Reproduction test for https://github.com/OpenFeign/feign/issues/2019
* Verifies what `Type` a Decoder receives when decoding a response of a suspend function.
*/
class SuspendDecoderTypeTest {

private lateinit var server: MockWebServer
private val recordedTypes: MutableList<Type> = mutableListOf()

@BeforeEach
fun beforeEach() {
server = MockWebServer()
server.start()
}

@AfterEach
fun afterEach() {
server.shutdown()
}

@Test
fun `decoder should receive the suspend function return type, not Object`(): Unit = runBlocking {
server.enqueue(MockResponse().setBody("""{"name":"ok"}"""))

val api = CoroutineFeign.builder<Void>()
.decoder { response, type ->
recordedTypes.add(type)
response.close()
TestObject("ok")
}
.target(TestApi::class.java, server.url("/").toString())

val result = api.suspendFun()

assertEquals("ok", result.name)
assertEquals(1, recordedTypes.size)
val received = recordedTypes[0]
assertTrue(
received == TestObject::class.java,
"Expected decoder type to be TestObject but was: $received"
)
}

@Test
fun `blocking builder decoder type for suspend function (documents issue 2019)`(): Unit = runBlocking {
server.enqueue(MockResponse().setBody("""{"name":"ok"}"""))

val api = feign.Feign.builder()
.contract(feign.kotlin.KotlinDelegatingContract(feign.Contract.Default()))
.decoder { response, type ->
recordedTypes.add(type)
response.close()
TestObject("ok")
}
.target(TestApi::class.java, server.url("/").toString())

val result = api.suspendFun()

assertEquals("ok", result.name)
assertEquals(1, recordedTypes.size)
val received = recordedTypes[0]
assertTrue(
received == TestObject::class.java,
"Expected decoder type to be TestObject but was: $received"
)
}

interface TestApi {
@RequestLine("GET /test")
suspend fun suspendFun(): TestObject
}

data class TestObject(val name: String)
}
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
<module>validation</module>
<module>validation-jakarta</module>
<module>vertx</module>
<module>webflux</module>
<module>feign-bom</module>
</modules>

Expand Down
Loading