diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/Tags.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/Tags.kt new file mode 100644 index 000000000..c9192fe23 --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/Tags.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * 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 okhttp3.internal + +import kotlin.reflect.KClass + +/** + * An immutable collection of key-value pairs implemented as a singly-linked list. + * + * Build up a collection by starting with [EmptyTags] and repeatedly calling [plus]. Each such call + * returns a new instance. + * + * This collection is optimized for safe concurrent access over a very small number of elements. + * + * This collection and is expected to hold fewer than 10 elements. Each operation is _O(N)_, and so + * building an instance with _N_ elements is _O(N**2)_. + */ +internal sealed class Tags { + /** + * Returns a tags instance that maps [key] to [value]. If [value] is null, this returns a tags + * instance that does not have any mapping for [key]. + */ + abstract fun plus( + key: KClass, + value: T?, + ): Tags + + abstract operator fun get(key: KClass): T? +} + +/** An empty tags. This is always the tail of a [LinkedTags] chain. */ +internal object EmptyTags : Tags() { + override fun plus( + key: KClass, + value: T?, + ): Tags = + when { + value != null -> LinkedTags(key, value, this) + else -> this + } + + override fun get(key: KClass): T? = null + + override fun toString() = "{}" +} + +/** + * An invariant of this implementation is that [next] must not contain a mapping for [key]. + * Otherwise, we would have two values for the same key. + */ +private class LinkedTags( + private val key: KClass, + private val value: K, + private val next: Tags, +) : Tags() { + override fun plus( + key: KClass, + value: T?, + ): Tags { + // Create a copy of this `LinkedTags` that doesn't have a mapping for `key`. + val thisMinusKey = + when { + key == this.key -> next // Subtract this! + + else -> { + val nextMinusKey = next.plus(key, null) + when { + nextMinusKey === next -> this // Same as the following line, but with fewer allocations. + else -> LinkedTags(this.key, this.value, nextMinusKey) + } + } + } + + // Return a new `Tags` that maps `key` to `value`. + return when { + value != null -> LinkedTags(key, value, thisMinusKey) + else -> thisMinusKey + } + } + + override fun get(key: KClass): T? = + when { + key == this.key -> key.java.cast(value) + else -> next[key] + } + + /** Returns a [toString] consistent with [Map], with elements in insertion order. */ + override fun toString(): String = + generateSequence>(seed = this) { it.next as? LinkedTags<*> } + .toList() + .reversed() + .joinToString(prefix = "{", postfix = "}") { "${it.key}=${it.value}" } +} diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/RequestTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/RequestTest.kt index 9dc98e5ed..13b066c07 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/RequestTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/RequestTest.kt @@ -579,6 +579,23 @@ class RequestTest { ) } + @Test + fun requestToStringIncludesTags() { + val request = + Request + .Builder() + .url("https://square.com/".toHttpUrl()) + .tag("hello") + .tag(5) + .build() + assertThat(request.toString()).isEqualTo( + "Request{method=GET, url=https://square.com/, tags={" + + "class kotlin.String=hello, " + + "class kotlin.Int=5" + + "}}", + ) + } + @Test fun gzip() { val mediaType = "text/plain; charset=utf-8".toMediaType() diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/internal/TagsTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/internal/TagsTest.kt new file mode 100644 index 000000000..66562c64a --- /dev/null +++ b/okhttp/src/jvmTest/kotlin/okhttp3/internal/TagsTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2025 Square, Inc. + * + * 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 okhttp3.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import org.junit.jupiter.api.Test + +class TagsTest { + @Test + fun emptyTags() { + val tags = EmptyTags + assertThat(tags[String::class]).isNull() + } + + @Test + fun singleElement() { + val tags = EmptyTags.plus(String::class, "hello") + assertThat(tags[String::class]).isEqualTo("hello") + } + + @Test + fun multipleElements() { + val tags = + EmptyTags + .plus(String::class, "hello") + .plus(Integer::class, 5 as Integer) + assertThat(tags[String::class]).isEqualTo("hello") + assertThat(tags[Integer::class]).isEqualTo(5) + } + + /** The implementation retains no nodes from the original linked list. */ + @Test + fun replaceFirstElement() { + val tags = + EmptyTags + .plus(String::class, "a") + .plus(Integer::class, 5 as Integer) + .plus(Boolean::class, true) + .plus(String::class, "b") + assertThat(tags[String::class]).isEqualTo("b") + assertThat(tags.toString()) + .isEqualTo("{class kotlin.Int=5, class kotlin.Boolean=true, class kotlin.String=b}") + } + + /** The implementation retains only the first node from the original linked list. */ + @Test + fun replaceMiddleElement() { + val tags = + EmptyTags + .plus(Integer::class, 5 as Integer) + .plus(String::class, "a") + .plus(Boolean::class, true) + .plus(String::class, "b") + assertThat(tags[String::class]).isEqualTo("b") + assertThat(tags.toString()) + .isEqualTo("{class kotlin.Int=5, class kotlin.Boolean=true, class kotlin.String=b}") + } + + /** The implementation retains all but the first node from the original linked list. */ + @Test + fun replaceLastElement() { + val tags = + EmptyTags + .plus(Integer::class, 5 as Integer) + .plus(Boolean::class, true) + .plus(String::class, "a") + .plus(String::class, "b") + assertThat(tags[String::class]).isEqualTo("b") + assertThat(tags.toString()) + .isEqualTo("{class kotlin.Int=5, class kotlin.Boolean=true, class kotlin.String=b}") + } + + /** The implementation retains no nodes from the original linked list. */ + @Test + fun removeFirstElement() { + val tags = + EmptyTags + .plus(String::class, "a") + .plus(Integer::class, 5 as Integer) + .plus(Boolean::class, true) + .plus(String::class, null) + assertThat(tags[String::class]).isNull() + assertThat(tags.toString()) + .isEqualTo("{class kotlin.Int=5, class kotlin.Boolean=true}") + } + + /** The implementation retains only the first node from the original linked list. */ + @Test + fun removeMiddleElement() { + val tags = + EmptyTags + .plus(Integer::class, 5 as Integer) + .plus(String::class, "a") + .plus(Boolean::class, true) + .plus(String::class, null) + assertThat(tags[String::class]).isNull() + assertThat(tags.toString()) + .isEqualTo("{class kotlin.Int=5, class kotlin.Boolean=true}") + } + + /** The implementation retains all but the first node from the original linked list. */ + @Test + fun removeLastElement() { + val tags = + EmptyTags + .plus(Integer::class, 5 as Integer) + .plus(Boolean::class, true) + .plus(String::class, "a") + .plus(String::class, null) + assertThat(tags[String::class]).isNull() + assertThat(tags.toString()) + .isEqualTo("{class kotlin.Int=5, class kotlin.Boolean=true}") + } + + @Test + fun removeUntilEmpty() { + val tags = + EmptyTags + .plus(Integer::class, 5 as Integer) + .plus(Boolean::class, true) + .plus(String::class, "a") + .plus(String::class, null) + .plus(Integer::class, null) + .plus(Boolean::class, null) + assertThat(tags).isEqualTo(EmptyTags) + assertThat(tags.toString()).isEqualTo("{}") + } + + @Test + fun removeAbsentFromEmpty() { + val tags = EmptyTags.plus(String::class, null) + assertThat(tags).isEqualTo(EmptyTags) + assertThat(tags.toString()).isEqualTo("{}") + } + + @Test + fun removeAbsentFromNonEmpty() { + val tags = + EmptyTags + .plus(String::class, "a") + .plus(Integer::class, null) + assertThat(tags[String::class]).isEqualTo("a") + assertThat(tags.toString()).isEqualTo("{class kotlin.String=a}") + } +}