diff --git a/stream-feeds-android-client/api/stream-feeds-android-client.api b/stream-feeds-android-client/api/stream-feeds-android-client.api index 97a82e3e2..a13aac336 100644 --- a/stream-feeds-android-client/api/stream-feeds-android-client.api +++ b/stream-feeds-android-client/api/stream-feeds-android-client.api @@ -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 ()V - public fun (Lio/getstream/feeds/android/client/api/file/Uploader;Lio/getstream/feeds/android/client/api/logging/LoggingConfig;)V - public synthetic fun (Lio/getstream/feeds/android/client/api/file/Uploader;Lio/getstream/feeds/android/client/api/logging/LoggingConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/feeds/android/client/api/file/Uploader;Lio/getstream/feeds/android/client/api/logging/LoggingConfig;Ljava/util/Map;)V + public synthetic fun (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; } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/FeedsClient.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/FeedsClient.kt index 39a6b22e6..810df3701 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/FeedsClient.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/FeedsClient.kt @@ -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, diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/FeedsConfig.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/FeedsConfig.kt index 90c8d316b..1b77a51d0 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/FeedsConfig.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/FeedsConfig.kt @@ -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 = emptyMap(), ) diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/Create.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/Create.kt index 82b97d142..cfc8d8383 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/Create.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/Create.kt @@ -159,7 +159,6 @@ internal fun createFeedsClient( tokenProvider: StreamTokenProvider, config: FeedsConfig, ): FeedsClient { - val logProvider = createLoggerProvider(config.loggingConfig.customLogger) val logger = logProvider.taggedLogger("FeedsClient") diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/CustomHeaders.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/CustomHeaders.kt new file mode 100644 index 000000000..975965d4f --- /dev/null +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/CustomHeaders.kt @@ -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, + 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) { + "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) { + "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) } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/HttpClientFactories.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/HttpClientFactories.kt index f22a63fce..48b8dd48f 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/HttpClientFactories.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/http/HttpClientFactories.kt @@ -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), + ), ) internal fun createRetrofit(endpointConfig: EndpointConfig, okHttpClient: OkHttpClient): Retrofit = diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/http/CustomHeadersTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/http/CustomHeadersTest.kt new file mode 100644 index 000000000..78bac5015 --- /dev/null +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/http/CustomHeadersTest.kt @@ -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(), 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, + original: Request = requestBuilder().build(), + ): Request { + val chain = mockk() + val proceeded = slot() + every { chain.request() } returns original + every { chain.proceed(capture(proceeded)) } returns mockk(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() + + 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 + } + } +} diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/http/HttpClientFactoriesTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/http/HttpClientFactoriesTest.kt index 7bdecbfe3..a2643a130 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/http/HttpClientFactoriesTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/http/HttpClientFactoriesTest.kt @@ -24,6 +24,7 @@ import io.mockk.mockk import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import retrofit2.converter.moshi.MoshiConverterFactory @@ -34,14 +35,14 @@ internal class HttpClientFactoriesTest { @Test fun `on createHttpConfig, then return StreamHttpConfig with correct configuration`() { val okHttpBuilder = OkHttpClient.Builder() - val config = - FeedsConfig(loggingConfig = LoggingConfig(httpLoggingLevel = HttpLoggingLevel.Basic)) - val result = createHttpConfig( okHttpBuilder = okHttpBuilder, logProvider = mockk(relaxed = true), - config = config, + config = + FeedsConfig( + loggingConfig = LoggingConfig(httpLoggingLevel = HttpLoggingLevel.Basic) + ), ) assertEquals(okHttpBuilder, result.httpBuilder) @@ -50,6 +51,35 @@ internal class HttpClientFactoriesTest { assertTrue(result.configuredInterceptors.first() is HttpLoggingInterceptor) } + @Test + fun `on createHttpConfig with custom headers, then add the custom headers interceptor first`() { + val result = + createHttpConfig( + okHttpBuilder = OkHttpClient.Builder(), + logProvider = mockk(relaxed = true), + config = FeedsConfig(customHeaders = mapOf("x-stream-ext" to "version=1.2.3")), + ) + + // The custom headers interceptor is private, so assert by position: it precedes logging. + val interceptors = result.configuredInterceptors.toList() + assertEquals(2, interceptors.size) + assertFalse(interceptors[0] is HttpLoggingInterceptor) + assertTrue(interceptors[1] is HttpLoggingInterceptor) + } + + @Test + fun `on createHttpConfig with only reserved headers, then add no custom headers interceptor`() { + val result = + createHttpConfig( + okHttpBuilder = OkHttpClient.Builder(), + logProvider = mockk(relaxed = true), + config = FeedsConfig(customHeaders = mapOf("Authorization" to "spoofed")), + ) + + assertEquals(1, result.configuredInterceptors.size) + assertTrue(result.configuredInterceptors.first() is HttpLoggingInterceptor) + } + @Test fun `on createRetrofit, then return Retrofit with correct configuration`() { val testEndpointConfig =