Skip to content

Commit

Permalink
chore: merge branch 'main' into dockerize-web-app
Browse files Browse the repository at this point in the history
  • Loading branch information
detj committed Aug 5, 2024
2 parents 69d8517 + 5ae48dd commit 5a7d359
Show file tree
Hide file tree
Showing 15 changed files with 853 additions and 306 deletions.
3 changes: 1 addition & 2 deletions measure-android/measure/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,12 @@ dependencies {
compileOnly(libs.androidx.compose.runtime.android)
compileOnly(libs.androidx.compose.ui)
compileOnly(libs.androidx.navigation.compose)
compileOnly(libs.squareup.okhttp)

implementation(libs.kotlinx.serialization.json)

implementation(libs.androidx.annotation)
implementation(libs.squareup.okio)
implementation(libs.squareup.okhttp)
implementation(libs.squareup.okhttp.logging)
implementation(libs.squareup.curtains)

testImplementation(libs.mockito.kotlin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal interface EventExporter {
*
* @return the response of the export operation.
*/
fun export(batchId: String, eventIds: List<String>): HttpResponse<Nothing?>?
fun export(batchId: String, eventIds: List<String>): HttpResponse?
}

internal class EventExporterImpl(
Expand All @@ -37,7 +37,7 @@ internal class EventExporterImpl(
@VisibleForTesting
internal val batchIdsInTransit = CopyOnWriteArrayList<String>()

override fun export(batchId: String, eventIds: List<String>): HttpResponse<Nothing?>? {
override fun export(batchId: String, eventIds: List<String>): HttpResponse? {
if (batchIdsInTransit.contains(batchId)) {
logger.log(LogLevel.Warning, "Batch $batchId is already in transit, skipping export")
return null
Expand Down Expand Up @@ -72,7 +72,7 @@ internal class EventExporterImpl(
}

private fun handleBatchProcessingResult(
response: HttpResponse<Nothing?>,
response: HttpResponse,
batchId: String,
events: List<EventPacket>,
attachments: List<AttachmentPacket>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package sh.measure.android.exporter

import sh.measure.android.storage.FileStorage

internal data class EventPacket(
val eventId: String,
val sessionId: String,
Expand All @@ -15,12 +13,7 @@ internal data class EventPacket(
val serializedUserDefinedAttributes: String?,
)

internal fun EventPacket.asFormDataPart(fileStorage: FileStorage): String {
val data = serializedData ?: if (serializedDataFilePath != null) {
fileStorage.getFile(serializedDataFilePath)?.readText()
?: throw IllegalStateException("No file found at path: $serializedDataFilePath")
} else {
throw IllegalStateException("EventPacket must have either serializedData or serializedDataFilePath")
}
return "{\"id\":\"$eventId\",\"session_id\":\"$sessionId\",\"user_triggered\":$userTriggered,\"timestamp\":\"$timestamp\",\"type\":\"$type\",\"$type\":$data,\"attachments\":$serializedAttachments,\"attribute\":$serializedAttributes}"
internal fun EventPacket.asFormDataPart(): String {
require(serializedData?.isNotEmpty() == true) { "serializedData is required for converting the event packet to form data" }
return "{\"id\":\"$eventId\",\"session_id\":\"$sessionId\",\"user_triggered\":$userTriggered,\"timestamp\":\"$timestamp\",\"type\":\"$type\",\"$type\":$serializedData,\"attachments\":$serializedAttachments,\"attribute\":$serializedAttributes}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ package sh.measure.android.exporter
/**
* Represents the response of an HTTP request. It can be either a success or an error.
*/
internal sealed class HttpResponse<out T> {
data class Success<out T>(val data: T? = null) : HttpResponse<T?>()
sealed class Error(val e: Exception?) : HttpResponse<Nothing>() {
data class RateLimitError(val exception: Exception? = null) : Error(exception)
data class ServerError(val exception: Exception? = null) : Error(exception)
data class ClientError(val exception: Exception? = null) : Error(exception)
data class UnknownError(val exception: Exception? = null) : Error(exception)
internal sealed class HttpResponse {
data object Success : HttpResponse()
sealed class Error : HttpResponse() {
data class ClientError(val code: Int) : Error()
data class ServerError(val code: Int) : Error()
data object RateLimitError : Error()
data class UnknownError(val exception: Exception? = null) : Error()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package sh.measure.android.exporter

import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.MalformedURLException
import java.net.URL

internal interface HttpClient {
fun sendMultipartRequest(
url: String,
method: String,
headers: Map<String, String>,
multipartData: List<MultipartData>,
): HttpResponse
}

/**
* An Http client that uses `HttpURLConnection` to send multipart requests. This class
* can be extended to support non-multipart requests in future.
*
* The main feature of this client is that it streams files part of the request directly to the
* socket, which allows to send large files without loading them entirely into the memory. It also
* configures the `HttpUrlConnection` to `setChunkedStreamingMode` to avoid buffering the request
* body in memory.
*/
internal class HttpUrlConnectionClient : HttpClient {
private val connectionTimeoutMs = 30_000
private val readTimeoutMs = 10_000
private val boundary = "--boundary-7MA4YWxkTrZu0gW"
private val maxRedirects = 5

override fun sendMultipartRequest(
url: String,
method: String,
headers: Map<String, String>,
multipartData: List<MultipartData>,
): HttpResponse {
return sendMultiPartRequestWithRedirects(url, method, headers, multipartData, 0)
}

private fun sendMultiPartRequestWithRedirects(
url: String,
method: String,
headers: Map<String, String>,
multipartData: List<MultipartData>,
redirectCount: Int,
): HttpResponse {
if (redirectCount >= maxRedirects) {
throw IOException("Too many redirects")
}
var connection: HttpURLConnection? = null
try {
connection = createConnection(url, method, headers)
streamMultipartData(connection.outputStream, multipartData)
if (isRedirect(connection.responseCode)) {
val location = connection.getHeaderField("Location")
?: throw IOException("Redirect location is missing")
val newUrl = resolveRedirectUrl(url, location)
connection.disconnect()
return sendMultiPartRequestWithRedirects(
url = newUrl,
method = method,
headers = headers,
multipartData = multipartData,
redirectCount = redirectCount + 1,
)
}
return processResponse(connection)
} catch (e: IOException) {
return HttpResponse.Error.UnknownError(e)
} finally {
connection?.disconnect()
}
}

private fun isRedirect(responseCode: Int): Boolean {
// Handling only 307 (Temporary Redirect) and 308 (Permanent Redirect) as the redirection
// status codes.
// 301, 302, and 303 change the method of the request to GET which is not suitable for
// multipart requests.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/307
return responseCode == 307 || responseCode == 308
}

@Throws(IOException::class)
private fun resolveRedirectUrl(baseUrl: String, location: String): String {
try {
val base = URL(baseUrl)
val resolved = URL(base, location)
return resolved.toString()
} catch (e: MalformedURLException) {
throw IOException("Invalid redirect URL", e)
}
}

private fun createConnection(
url: String,
method: String,
headers: Map<String, String>,
): HttpURLConnection {
return (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
doOutput = true
setChunkedStreamingMode(0)
connectTimeout = connectionTimeoutMs
readTimeout = readTimeoutMs
setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
headers.forEach { (key, value) -> setRequestProperty(key, value) }
}
}

private fun streamMultipartData(
outputStream: OutputStream,
multipartData: List<MultipartData>,
) {
val writer = OutputStreamWriter(outputStream)

multipartData.forEach { data ->
writeMultipartPart(writer, data)
}

writeClosingBoundary(writer)
}

private fun writeMultipartPart(writer: OutputStreamWriter, data: MultipartData) {
val (headers, content) = when (data) {
is MultipartData.FormField -> getFormFieldPart(data)
is MultipartData.FileData -> getFileDataPart(data)
}

writeBoundary(writer)
writeHeaders(writer, headers, content.length)
writeContent(writer, content)
}

private fun writeBoundary(writer: OutputStreamWriter) {
writer.write("--$boundary\r\n")
}

private fun writeHeaders(
writer: OutputStreamWriter,
headers: Map<String, String>,
contentLength: Int,
) {
headers.forEach { (key, value) ->
writer.write("$key: $value\r\n")
}
writer.write("Content-Length: $contentLength\r\n")
writer.write("\r\n")
}

private fun writeContent(writer: OutputStreamWriter, content: String) {
writer.write(content)
writer.write("\r\n")
writer.flush()
}

private fun writeClosingBoundary(writer: OutputStreamWriter) {
writer.write("--$boundary--\r\n")
writer.flush()
}

private fun getFormFieldPart(data: MultipartData.FormField): Pair<Map<String, String>, String> {
val headers = mapOf(
"Content-Disposition" to "form-data; name=\"${data.name}\"",
)
return headers to data.value
}

private fun getFileDataPart(data: MultipartData.FileData): Pair<Map<String, String>, String> {
val headers = mapOf(
"Content-Disposition" to "form-data; name=\"${data.name}\"; filename=\"${data.filename}\"",
"Content-Type" to data.contentType,
)
val content = data.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }
return headers to content
}

private fun processResponse(connection: HttpURLConnection): HttpResponse {
return when (val responseCode = connection.responseCode) {
in 200..299 -> HttpResponse.Success
429 -> HttpResponse.Error.RateLimitError
in 400..499 -> HttpResponse.Error.ClientError(responseCode)
in 500..599 -> HttpResponse.Error.ServerError(responseCode)
else -> HttpResponse.Error.UnknownError()
}
}
}

internal sealed class MultipartData {
data class FormField(val name: String, val value: String) : MultipartData()
data class FileData(
val name: String,
val filename: String,
val contentType: String,
val inputStream: InputStream,
) : MultipartData()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package sh.measure.android.exporter

import sh.measure.android.logger.LogLevel
import sh.measure.android.logger.Logger
import sh.measure.android.storage.FileStorage
import java.io.InputStream

internal interface MultipartDataFactory {
/**
* Creates a [MultipartData] object from an [EventPacket].
*
* If the [EventPacket] contains serialized data, the [MultipartData] object will be created
* as a form field. If the [EventPacket] contains a file path, the [MultipartData] object will be
* created as a file data in the multipart request.
*
* @param eventPacket the [EventPacket] to create the [MultipartData] object from
* @return [MultipartData] object or null if the [EventPacket] does not contain serialized data
* or file path
*/
fun createFromEventPacket(eventPacket: EventPacket): MultipartData?

/**
* Creates a [MultipartData] object from an [AttachmentPacket].
*
* The [MultipartData] object will be created as a file data in the multipart request.
*
* @param attachmentPacket the [AttachmentPacket] to create the [MultipartData] object from
* @return [MultipartData] object or null if the file at the [AttachmentPacket]'s file path
*/
fun createFromAttachmentPacket(attachmentPacket: AttachmentPacket): MultipartData?
}

internal class MultipartDataFactoryImpl(
private val logger: Logger,
private val fileStorage: FileStorage,
) : MultipartDataFactory {

internal companion object {
const val ATTACHMENT_NAME_PREFIX = "blob-"
const val EVENT_FORM_NAME = "event"
}

override fun createFromEventPacket(eventPacket: EventPacket): MultipartData? {
return when {
eventPacket.serializedData != null -> {
MultipartData.FormField(
name = EVENT_FORM_NAME,
value = eventPacket.asFormDataPart(),
)
}

eventPacket.serializedDataFilePath != null -> {
getFileInputStream(eventPacket.serializedDataFilePath)?.let { inputStream ->
MultipartData.FileData(
name = EVENT_FORM_NAME,
filename = eventPacket.eventId,
contentType = "application/json",
inputStream = inputStream,
)
}
}

else -> {
logger.log(
LogLevel.Error,
"Event packet (id=${eventPacket.eventId}) does not contain serialized data or file path",
)
null
}
}
}

override fun createFromAttachmentPacket(attachmentPacket: AttachmentPacket): MultipartData? {
val name = getAttachmentFormDataName(attachmentPacket)
val fileInputStream = getFileInputStream(attachmentPacket.filePath)
return if (fileInputStream != null) {
MultipartData.FileData(
name = name,
filename = name,
contentType = "application/octet-stream",
inputStream = fileInputStream,
)
} else {
null
}
}

private fun getFileInputStream(filePath: String): InputStream? {
return fileStorage.getFile(filePath)?.inputStream().also { fileInputStream ->
if (fileInputStream == null) {
logger.log(LogLevel.Error, "No file found at path: $filePath")
}
}
}

private fun getAttachmentFormDataName(attachmentPacket: AttachmentPacket): String =
"$ATTACHMENT_NAME_PREFIX${attachmentPacket.id}"
}
Loading

0 comments on commit 5a7d359

Please sign in to comment.