Skip to content

Commit e3c7d1f

Browse files
authored
Add a base64 encoding implementation (#23)
* add own implementation of base64 * fix import * make httpclient stateful, cache authHeader to prevent base64 recomputation, improve httpclient tests
1 parent 95b90f2 commit e3c7d1f

File tree

8 files changed

+119
-30
lines changed

8 files changed

+119
-30
lines changed

core/src/main/java/com/segment/analytics/kotlin/core/HTTPClient.kt

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.segment.analytics.kotlin.core
22

3+
import com.segment.analytics.kotlin.core.Constants.LIBRARY_VERSION
4+
import com.segment.analytics.kotlin.core.utilities.encodeToBase64
35
import java.io.BufferedReader
46
import java.io.Closeable
57
import java.io.IOException
@@ -8,11 +10,12 @@ import java.io.OutputStream
810
import java.net.HttpURLConnection
911
import java.net.MalformedURLException
1012
import java.net.URL
11-
import java.util.Base64
1213
import java.util.zip.GZIPOutputStream
1314

14-
class HTTPClient {
15-
fun settings(writeKey: String): Connection {
15+
class HTTPClient(private val writeKey: String) {
16+
internal val authHeader = authorizationHeader(writeKey)
17+
18+
fun settings(): Connection {
1619
val connection: HttpURLConnection =
1720
openConnection("https://cdn-settings.segment.com/v1/projects/$writeKey/settings")
1821
val responseCode = connection.responseCode
@@ -23,9 +26,9 @@ class HTTPClient {
2326
return connection.createGetConnection()
2427
}
2528

26-
fun upload(apiHost: String, writeKey: String): Connection {
27-
val connection: HttpURLConnection = openConnection("https://$apiHost/import")
28-
connection.setRequestProperty("Authorization", authorizationHeader(writeKey))
29+
fun upload(apiHost: String): Connection {
30+
val connection: HttpURLConnection = openConnection("https://$apiHost/batch")
31+
connection.setRequestProperty("Authorization", authHeader)
2932
connection.setRequestProperty("Content-Encoding", "gzip")
3033
connection.doOutput = true
3134
connection.setChunkedStreamingMode(0)
@@ -34,16 +37,15 @@ class HTTPClient {
3437

3538
private fun authorizationHeader(writeKey: String): String {
3639
val auth = "$writeKey:"
37-
return "Basic " + Base64.getEncoder().encodeToString(auth.toByteArray())
40+
return "Basic ${encodeToBase64(auth)}"
3841
}
3942

4043
/**
4144
* Configures defaults for connections opened with [.upload], and [ ][.projectSettings].
4245
*/
4346
@Throws(IOException::class)
4447
private fun openConnection(url: String): HttpURLConnection {
45-
val requestedURL: URL
46-
requestedURL = try {
48+
val requestedURL: URL = try {
4749
URL(url)
4850
} catch (e: MalformedURLException) {
4951
throw IOException("Attempted to use malformed url: $url", e)
@@ -55,7 +57,7 @@ class HTTPClient {
5557
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8")
5658
connection.setRequestProperty(
5759
"User-Agent",
58-
"analytics-kotlin/1.0.0"
60+
"analytics-kotlin/$LIBRARY_VERSION"
5961
)
6062
connection.doInput = true
6163
return connection

core/src/main/java/com/segment/analytics/kotlin/core/SegmentDestination.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class SegmentDestination(
3131
) : DestinationPlugin() {
3232

3333
override val key: String = "Segment.io"
34-
internal val httpClient: HTTPClient = HTTPClient()
34+
internal val httpClient: HTTPClient = HTTPClient(apiKey)
3535
internal lateinit var storage: Storage
3636
lateinit var flushScheduler: ScheduledExecutorService
3737
internal val eventCount = AtomicInteger(0)
@@ -135,7 +135,7 @@ class SegmentDestination(
135135
eventCount.set(0)
136136
for (fileUrl in fileUrls) {
137137
try {
138-
val connection = httpClient.upload(apiHost, apiKey)
138+
val connection = httpClient.upload(apiHost)
139139
val file = File(fileUrl)
140140
// flush is executed in a thread pool and file could have been deleted by another thread
141141
if (!file.exists()) {

core/src/main/java/com/segment/analytics/kotlin/core/Settings.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ fun Analytics.checkSettings() {
5656
analyticsScope.launch(ioDispatcher) {
5757
log("Fetching settings on ${Thread.currentThread().name}")
5858
val settingsObj: Settings? = try {
59-
val connection = HTTPClient().settings(writeKey)
59+
val connection = HTTPClient(writeKey).settings()
6060
val settingsString =
6161
connection.inputStream?.bufferedReader()?.use(BufferedReader::readText) ?: ""
6262
log("Fetched Settings: $settingsString")
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.segment.analytics.kotlin.core.utilities
2+
3+
// Encode string to base64
4+
fun encodeToBase64(str: String) = encodeToBase64(str.toByteArray())
5+
6+
// Encode byte-array to base64
7+
fun encodeToBase64(bytes: ByteArray) = buildString {
8+
val wData = ByteArray(3) // working data
9+
var i = 0
10+
while (i < bytes.size) {
11+
val leftover = bytes.size - i
12+
val available = if (leftover >= 3) {
13+
3
14+
} else {
15+
leftover
16+
}
17+
for (j in 0 until available) {
18+
wData[j] = bytes[i++]
19+
}
20+
for (j in 2 downTo available) {
21+
wData[j] = 0 // clear out
22+
}
23+
// Given a 3 byte block (24 bits), encode it to 4 base64 characters
24+
val chunk = ((wData[0].toInt() and 0xFF) shl 16) or
25+
((wData[1].toInt() and 0xFF) shl 8) or
26+
(wData[2].toInt() and 0xFF)
27+
28+
// if we have too little characters in this block, we add padding
29+
val padCount = (wData.size - available) * 8 / 6
30+
31+
// encode to base64
32+
for (index in 3 downTo padCount) { // 4 base64 characters
33+
val char = (chunk shr (6 * index)) and 0x3f // 0b00111111
34+
append(char.base64Val())
35+
}
36+
37+
// add padding if needed
38+
repeat(padCount) { append("=") }
39+
}
40+
}
41+
42+
private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
43+
private fun Int.base64Val(): Char = ALPHABET[this]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.segment.analytics.kotlin.core
2+
3+
import com.segment.analytics.kotlin.core.utilities.encodeToBase64
4+
import org.junit.jupiter.api.Assertions.assertEquals
5+
import org.junit.jupiter.api.Test
6+
import org.junit.jupiter.api.TestInstance
7+
8+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
9+
class Base64UtilsTest {
10+
11+
@Test
12+
fun testBase64Encoding() {
13+
assertEquals(encodeToBase64(""), "")
14+
assertEquals(encodeToBase64("f"), "Zg==")
15+
assertEquals(encodeToBase64("fo"), "Zm8=")
16+
assertEquals(encodeToBase64("foo"), "Zm9v")
17+
assertEquals(encodeToBase64("foob"), "Zm9vYg==")
18+
assertEquals(encodeToBase64("fooba"), "Zm9vYmE=")
19+
assertEquals(encodeToBase64("foobar"), "Zm9vYmFy")
20+
}
21+
}
Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,54 @@
11
package com.segment.analytics.kotlin.core
22

3-
import kotlinx.coroutines.runBlocking
4-
import kotlinx.serialization.decodeFromString
5-
import kotlinx.serialization.json.Json
3+
import com.segment.analytics.kotlin.core.Constants.LIBRARY_VERSION
64
import org.junit.jupiter.api.Assertions.*
75
import org.junit.jupiter.api.BeforeEach
86
import org.junit.jupiter.api.Test
97
import org.junit.jupiter.api.TestInstance
10-
import java.io.BufferedReader
118

129
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
1310
class HTTPClientTests {
1411

15-
private val httpClient = HTTPClient()
12+
private val httpClient = HTTPClient("1vNgUqwJeCHmqgI9S1sOm9UHCyfYqbaQ")
1613

1714
@BeforeEach
1815
fun setup() {
1916

2017
}
2118

2219
@Test
23-
fun `fetch settings works`() = runBlocking {
24-
val connection = httpClient.settings("1vNgUqwJeCHmqgI9S1sOm9UHCyfYqbaQ")
25-
val settingsString = connection.inputStream?.bufferedReader()?.use(BufferedReader::readText) ?: ""
26-
val settingsObj: Settings = Json { ignoreUnknownKeys = true }.decodeFromString(settingsString)
27-
assertEquals(emptyJsonObject, settingsObj.edgeFunction)
28-
assertNotEquals(emptyJsonObject, settingsObj.integrations)
29-
assertEquals(1, settingsObj.integrations.size)
20+
fun `authHeader is correctly computed`() {
21+
assertEquals("Basic MXZOZ1Vxd0plQ0htcWdJOVMxc09tOVVIQ3lmWXFiYVE6", httpClient.authHeader)
22+
}
23+
24+
@Test
25+
fun `upload connection has correct configuration`() {
26+
httpClient.settings().connection.let {
27+
assertEquals(
28+
"https://cdn-settings.segment.com/v1/projects/1vNgUqwJeCHmqgI9S1sOm9UHCyfYqbaQ/settings",
29+
it.url.toString()
30+
)
31+
assertEquals(
32+
"analytics-kotlin/$LIBRARY_VERSION",
33+
it.getRequestProperty("User-Agent")
34+
)
35+
}
36+
}
37+
38+
@Test
39+
fun `settings connection has correct configuration`() {
40+
httpClient.upload("api.segment.io/v1").connection.let {
41+
assertEquals(
42+
"https://api.segment.io/v1/batch",
43+
it.url.toString()
44+
)
45+
assertEquals(
46+
"analytics-kotlin/$LIBRARY_VERSION",
47+
it.getRequestProperty("User-Agent")
48+
)
49+
assertEquals("gzip", it.getRequestProperty("Content-Encoding"))
50+
// ideally we would also test if auth Header is set, but due to security concerns this
51+
// is not possible https://bit.ly/3CVpR3J
52+
}
3053
}
3154
}

core/src/test/kotlin/com/segment/analytics/kotlin/core/SegmentDestinationTests.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ class SegmentDestinationTests {
196196
outputBytes = (outputStream as ByteArrayOutputStream).toByteArray()
197197
}
198198
}
199-
every { anyConstructed<HTTPClient>().upload(any(), any()) } returns connection
199+
every { anyConstructed<HTTPClient>().upload(any()) } returns connection
200200

201201
assertEquals(trackEvent, destSpy.track(trackEvent))
202202
assertEquals(1, segmentDestination.eventCount.get())
@@ -246,7 +246,7 @@ class SegmentDestinationTests {
246246
throw HTTPException(400, "", null)
247247
}
248248
}
249-
every { anyConstructed<HTTPClient>().upload(any(), any()) } returns connection
249+
every { anyConstructed<HTTPClient>().upload(any()) } returns connection
250250

251251
assertEquals(trackEvent, destSpy.track(trackEvent))
252252
assertEquals(1, segmentDestination.eventCount.get())
@@ -284,7 +284,7 @@ class SegmentDestinationTests {
284284
throw HTTPException(429, "", null)
285285
}
286286
}
287-
every { anyConstructed<HTTPClient>().upload(any(), any()) } returns connection
287+
every { anyConstructed<HTTPClient>().upload(any()) } returns connection
288288

289289
assertEquals(trackEvent, destSpy.track(trackEvent))
290290
assertEquals(1, segmentDestination.eventCount.get())
@@ -328,7 +328,7 @@ class SegmentDestinationTests {
328328
throw HTTPException(500, "", null)
329329
}
330330
}
331-
every { anyConstructed<HTTPClient>().upload(any(), any()) } returns connection
331+
every { anyConstructed<HTTPClient>().upload(any()) } returns connection
332332

333333
assertEquals(trackEvent, destSpy.track(trackEvent))
334334
assertEquals(1, segmentDestination.eventCount.get())

core/src/test/kotlin/com/segment/analytics/kotlin/core/SettingsTests.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class SettingsTests {
3333
)
3434
val httpConnection: HttpURLConnection = mockk()
3535
val connection = object : Connection(httpConnection, settingsStream, null) {}
36-
every { anyConstructed<HTTPClient>().settings(any()) } returns connection
36+
every { anyConstructed<HTTPClient>().settings() } returns connection
3737
}
3838

3939
@BeforeEach

0 commit comments

Comments
 (0)