-
Notifications
You must be signed in to change notification settings - Fork 1
Support custom request headers in FeedsConfig #211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
|
||
| "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
|
||
| "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 |
|---|---|---|
| @@ -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 | ||
| } | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.