Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -865,8 +865,9 @@ public final class io/getstream/feeds/android/client/api/model/FeedVisibility$Vi

public final class io/getstream/feeds/android/client/api/model/FeedsConfig {
public fun <init> ()V
public fun <init> (Lio/getstream/feeds/android/client/api/file/Uploader;Lio/getstream/feeds/android/client/api/logging/LoggingConfig;)V
public synthetic fun <init> (Lio/getstream/feeds/android/client/api/file/Uploader;Lio/getstream/feeds/android/client/api/logging/LoggingConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Lio/getstream/feeds/android/client/api/file/Uploader;Lio/getstream/feeds/android/client/api/logging/LoggingConfig;Ljava/util/Map;)V
public synthetic fun <init> (Lio/getstream/feeds/android/client/api/file/Uploader;Lio/getstream/feeds/android/client/api/logging/LoggingConfig;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCustomHeaders ()Ljava/util/Map;
public final fun getCustomUploader ()Lio/getstream/feeds/android/client/api/file/Uploader;
public final fun getLoggingConfig ()Lio/getstream/feeds/android/client/api/logging/LoggingConfig;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,8 @@ public interface FeedsClient {
* @param apiKey The API key for the client.
* @param user The user associated with the client.
* @param tokenProvider The provider for user tokens, used for refreshing tokens as needed.
* @param config Configuration for the client, such as custom file uploader.
* @param config Configuration for the client, such as a custom file uploader, logging, or extra
* headers to send with every HTTP request. See [FeedsConfig].
*/
public fun FeedsClient(
context: Context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ import io.getstream.feeds.android.client.api.logging.LoggingConfig
* @param customUploader Optional [FeedUploader] implementation for overriding the default CDN.
* @param loggingConfig Configuration for logging within the FeedsClient. See [LoggingConfig] for
* more details.
* @param customHeaders Extra headers sent with every API request, over HTTP only and not on the
* WebSocket. Headers controlled by the SDK or its HTTP stack cannot be overridden and are ignored
* with a warning. Building the client rejects a name or value that is not valid HTTP, or two
* names differing only in case, which HTTP treats as one header.
*/
public class FeedsConfig(
public val customUploader: FeedUploader? = null,
public val loggingConfig: LoggingConfig = LoggingConfig(),
public val customHeaders: Map<String, String> = emptyMap(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ internal fun createFeedsClient(
tokenProvider: StreamTokenProvider,
config: FeedsConfig,
): FeedsClient {

val logProvider = createLoggerProvider(config.loggingConfig.customLogger)
val logger = logProvider.taggedLogger("FeedsClient")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE
*
* 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 io.getstream.feeds.android.client.internal.http

import io.getstream.android.core.api.log.StreamLoggerProvider
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response

/**
* Returns an interceptor applying [customHeaders] to every API request, or null if none of them
* survive: [RESERVED_HEADERS] are dropped with a warning.
*
* @throws IllegalArgumentException if a name or value is not valid HTTP, or if two names differ
* only in case, which HTTP treats as one header. The message names the header but never its
* value, which may be a secret.
*/
internal fun createCustomHeadersInterceptor(
customHeaders: Map<String, String>,
logProvider: StreamLoggerProvider,
): Interceptor? {
if (customHeaders.isEmpty()) return null

val logger = logProvider.taggedLogger("FeedCustomHeaders")
val builder = Headers.Builder()
customHeaders.forEach { (name, value) ->
if (name.isReservedHeader()) {
logger.w {
"Ignoring custom header '$name': it is set by the SDK and cannot be overridden."
}
} else {
require(builder.get(name) == null) {

Check warning on line 46 in stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/CustomHeaders.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace function call with indexed accessor.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-feeds-android&issues=AaAk4uO50VcEImD9_0D4&open=AaAk4uO50VcEImD9_0D4&pullRequest=211
"Duplicate entry in FeedsConfig.customHeaders: '$name' differs only in case from a " +
"name already supplied, and HTTP treats the two as one header."
}
require(runCatching { builder.set(name, value) }.isSuccess) {

Check warning on line 50 in stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/CustomHeaders.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace function call with indexed accessor.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-feeds-android&issues=AaAk4uO50VcEImD9_0D5&open=AaAk4uO50VcEImD9_0D5&pullRequest=211
"Invalid entry in FeedsConfig.customHeaders for name '$name'. Header names and " +
"values must be valid HTTP: visible ASCII, no line breaks."
}
}
}
return builder.build().takeIf { it.size > 0 }?.let(::CustomHeadersInterceptor)
}

/**
* Applies [headers] to every API request.
*
* Private so that [createCustomHeadersInterceptor] is the only way to get one, and the headers it
* holds have always been through the checks there.
*/
private class CustomHeadersInterceptor(private val headers: Headers) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().newBuilder()
for (i in 0 until headers.size) {
builder.header(headers.name(i), headers.value(i))
}
return chain.proceed(builder.build())
}
}

/**
* Headers the SDK or OkHttp controls, which callers must not override.
*
* The first three come from `stream-core-android`'s interceptors, registered via
* `StreamHttpConfig.automaticInterceptors`. The rest are OkHttp's, which sets them only when absent
* or when the request has a body, so a caller could otherwise take them over: overriding
* `Accept-Encoding` stops it decompressing responses, `Host` changes routing, `Connection` breaks
* connection pooling, and the framing headers corrupt a bodiless request. `User-Agent` and `Cookie`
* are deliberately left overridable.
*/
private val RESERVED_HEADERS =
listOf(
"Authorization",
"stream-auth-type",
"X-Stream-Client",
"Content-Type",
"Content-Length",
"Transfer-Encoding",
"Connection",
"Accept-Encoding",
"Host",
)

private fun String.isReservedHeader() = RESERVED_HEADERS.any { it.equals(this, ignoreCase = true) }
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ internal fun createHttpConfig(
StreamHttpConfig(
httpBuilder = okHttpBuilder,
automaticInterceptors = true,
// Before the logging interceptor, so the custom headers appear in the logs.
configuredInterceptors =
setOf(createLoggingInterceptor(logProvider, config.loggingConfig.httpLoggingLevel)),
setOfNotNull(
createCustomHeadersInterceptor(config.customHeaders, logProvider),
createLoggingInterceptor(logProvider, config.loggingConfig.httpLoggingLevel),
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

internal fun createRetrofit(endpointConfig: EndpointConfig, okHttpClient: OkHttpClient): Retrofit =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE
*
* 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 io.getstream.feeds.android.client.internal.http

import io.getstream.android.core.api.log.StreamLogger
import io.getstream.android.core.api.log.StreamLoggerProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test

internal class CustomHeadersTest {

@Test
fun `on no headers, then no interceptor`() {
assertNull(createCustomHeadersInterceptor(emptyMap(), RecordingLogger().asProvider()))
}

@Test
fun `on only reserved headers, then no interceptor but a warning for each`() {
val logger = RecordingLogger()

val interceptor =
createCustomHeadersInterceptor(
// Reserved, spelled with a different case on purpose.
mapOf("authorization" to "spoofed", "ACCEPT-ENCODING" to "identity"),
logger.asProvider(),
)

assertNull(interceptor)
assertEquals(2, logger.warnings.size)
}

@Test
fun `on reserved names differing only in case, then drop them without complaining of a duplicate`() {
// Both are dropped anyway, so failing as a duplicate would misdiagnose the cause.
val logger = RecordingLogger()

val interceptor =
createCustomHeadersInterceptor(
mapOf("Authorization" to "a", "authorization" to "b", "x-stream-ext" to "keep"),
logger.asProvider(),
)

assertNotNull(interceptor)
assertEquals(2, logger.warnings.size)
}

@Test
fun `on valid headers, then an interceptor and no warnings`() {
val logger = RecordingLogger()

val interceptor =
createCustomHeadersInterceptor(
mapOf("x-stream-ext" to "version=1.2.3"),
logger.asProvider(),
)

assertNotNull(interceptor)
assertEquals(emptyList<String>(), logger.warnings)
}

@Test
fun `on an invalid header name, then throw`() {
// A space is not legal in a header name.
val error =
assertThrows(IllegalArgumentException::class.java) {
createCustomHeadersInterceptor(mapOf("bad name" to "value"), noLogger())
}

assertTrue(error.message!!.contains("bad name"))
}

@Test
fun `on an invalid header value, then throw without echoing the value`() {
// A newline is not legal in a header value. The value may be a secret, so it must not
// appear in the message.
val error =
assertThrows(IllegalArgumentException::class.java) {
createCustomHeadersInterceptor(
mapOf("x-api-secret" to "s3cret\nsmuggled"),
noLogger(),
)
}

assertTrue(error.message!!.contains("x-api-secret"))
assertFalse(error.message!!.contains("s3cret"))
}

@Test
fun `on names differing only in case, then throw rather than pick one`() {
// Which one won would depend on the iteration order of a map the caller supplied.
val error =
assertThrows(IllegalArgumentException::class.java) {
createCustomHeadersInterceptor(
mapOf("x-stream-ext" to "first", "X-Stream-Ext" to "second"),
noLogger(),
)
}

assertTrue(error.message!!.contains("X-Stream-Ext"))
}

@Test
fun `on intercept, then add the headers to the request`() {
val request = intercept(mapOf("x-stream-ext" to "version=1.2.3"))

assertEquals("version=1.2.3", request.header("x-stream-ext"))
}

@Test
fun `on intercept, then leave the headers already on the request alone`() {
val original = requestBuilder().addHeader("Authorization", "real-token").build()

val request = intercept(mapOf("x-stream-ext" to "version=1.2.3"), original)

assertEquals("real-token", request.header("Authorization"))
assertEquals("version=1.2.3", request.header("x-stream-ext"))
}

@Test
fun `on intercept with a header already on the request, then replace it instead of appending`() {
val original = requestBuilder().addHeader("x-stream-ext", "old").build()

val request = intercept(mapOf("x-stream-ext" to "new"), original)

assertEquals(listOf("new"), request.headers("x-stream-ext"))
}

private fun requestBuilder() = Request.Builder().url("https://example.com/api/v2/feeds")

private fun intercept(
customHeaders: Map<String, String>,
original: Request = requestBuilder().build(),
): Request {
val chain = mockk<Interceptor.Chain>()
val proceeded = slot<Request>()
every { chain.request() } returns original
every { chain.proceed(capture(proceeded)) } returns mockk<Response>(relaxed = true)

createCustomHeadersInterceptor(customHeaders, noLogger())!!.intercept(chain)

return proceeded.captured
}

private fun noLogger(): StreamLoggerProvider = mockk(relaxed = true)

/** Captures warnings, which a relaxed mock would swallow. */
private class RecordingLogger : StreamLogger {
val warnings = mutableListOf<String>()

override fun log(
level: StreamLogger.LogLevel,
throwable: Throwable?,
message: () -> String,
) {
if (level == StreamLogger.LogLevel.Warning) warnings += message()
}

fun asProvider() =
object : StreamLoggerProvider {
override fun taggedLogger(tag: String): StreamLogger = this@RecordingLogger
}
}
}
Loading
Loading