From 9b5358df3f3dd3e8f870b86ba2abd19e77a49aca Mon Sep 17 00:00:00 2001 From: Richard Zadorozny Date: Thu, 22 Jan 2026 14:49:51 -0500 Subject: [PATCH] Fix Request Bodies not showing up in 'one-shot' requests --- .../ui/inspector/InspectorPayloadView.kt | 69 ++-- .../desktop/ui/inspector/RequestDetailView.kt | 54 ++- .../ui/inspector/WebSocketDetailView.kt | 32 +- .../network/okhttp3/SnapOOkHttpInterceptor.kt | 371 +++++++++++++++++- .../demo/httpurlconnection/MainActivity.kt | 113 ++++-- .../openai/snapo/demo/ktor/MainActivity.kt | 134 +++++-- .../com/openai/snapo/demo/MainActivity.kt | 29 ++ 7 files changed, 678 insertions(+), 124 deletions(-) diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorPayloadView.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorPayloadView.kt index c9a1e1c..cb3c869 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorPayloadView.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorPayloadView.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -706,21 +707,23 @@ private fun inlineJsonOutlineActions( ): (@Composable RowScope.(JsonOutlineNode) -> Unit)? { if (!enabled) return null return { _ -> - Row( - horizontalArrangement = Arrangement.spacedBy(Spacings.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - if (hasToggle) { - InspectorInlineTextToggle( - label = if (localPretty) "PRETTY" else "RAW", - onClick = onTogglePretty, - ) - } - if (hasCopy) { - InspectorInlineCopyButton( - isCopied = didCopy, - onCopy = onCopy, - ) + DisableSelection { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacings.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasToggle) { + InspectorInlineTextToggle( + label = if (localPretty) "PRETTY" else "RAW", + onClick = onTogglePretty, + ) + } + if (hasCopy) { + InspectorInlineCopyButton( + isCopied = didCopy, + onCopy = onCopy, + ) + } } } } @@ -737,26 +740,28 @@ private fun ControlsRow( ) { if (!hasToggle && !showsCopyButton) return - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { + DisableSelection { Row( - horizontalArrangement = Arrangement.spacedBy(Spacings.sm), + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), ) { - if (hasToggle) { - InspectorInlineTextToggle( - label = if (prettyChecked) "PRETTY" else "RAW", - onClick = onPrettyToggle, - ) - } - if (showsCopyButton) { - InspectorInlineCopyButton( - isCopied = didCopy, - onCopy = onCopy, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(Spacings.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasToggle) { + InspectorInlineTextToggle( + label = if (prettyChecked) "PRETTY" else "RAW", + onClick = onPrettyToggle, + ) + } + if (showsCopyButton) { + InspectorInlineCopyButton( + isCopied = didCopy, + onCopy = onCopy, + ) + } } } } diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/RequestDetailView.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/RequestDetailView.kt index 9fa07be..c5042ad 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/RequestDetailView.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/RequestDetailView.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,6 +48,8 @@ fun RequestDetailView( var requestBodyPretty by remember(request.id) { mutableStateOf(request.requestBody?.prettyPrintedText != null) } var responseBodyPretty by remember(request.id) { mutableStateOf(request.responseBody?.prettyPrintedText != null) } + var requestBodyPrettyTouched by remember(request.id) { mutableStateOf(false) } + var responseBodyPrettyTouched by remember(request.id) { mutableStateOf(false) } var didCopyAllEvents by remember(request.id) { mutableStateOf(false) } @@ -70,8 +73,14 @@ fun RequestDetailView( onResponseHeadersExpandedChange = { uiState.responseHeadersExpanded = it }, onResponseBodyExpandedChange = { uiState.responseBodyExpanded = it }, onStreamExpandedChange = { uiState.streamExpanded = it }, - onRequestBodyPrettyChange = { requestBodyPretty = it }, - onResponseBodyPrettyChange = { responseBodyPretty = it }, + onRequestBodyPrettyChange = { + requestBodyPrettyTouched = true + requestBodyPretty = it + }, + onResponseBodyPrettyChange = { + responseBodyPrettyTouched = true + responseBodyPretty = it + }, onCopyAllEvents = { NetworkInspectorCopyExporter.copyStreamEventsRaw(request.streamEvents) didCopyAllEvents = true @@ -82,11 +91,47 @@ fun RequestDetailView( state = state, actions = actions, ) + RequestDetailEffects( + request = request, + requestBodyPrettyTouched = requestBodyPrettyTouched, + responseBodyPrettyTouched = responseBodyPrettyTouched, + onRequestBodyPrettyAvailable = { requestBodyPretty = true }, + onResponseBodyPrettyAvailable = { responseBodyPretty = true }, + didCopyAllEvents = didCopyAllEvents, + onClearCopyAllEvents = { didCopyAllEvents = false }, + ) +} + +@Composable +private fun RequestDetailEffects( + request: NetworkInspectorRequestUiModel, + requestBodyPrettyTouched: Boolean, + responseBodyPrettyTouched: Boolean, + onRequestBodyPrettyAvailable: () -> Unit, + onResponseBodyPrettyAvailable: () -> Unit, + didCopyAllEvents: Boolean, + onClearCopyAllEvents: () -> Unit, +) { + val latestOnRequestBodyPrettyAvailable by rememberUpdatedState(onRequestBodyPrettyAvailable) + val latestOnResponseBodyPrettyAvailable by rememberUpdatedState(onResponseBodyPrettyAvailable) + val latestOnClearCopyAllEvents by rememberUpdatedState(onClearCopyAllEvents) + + LaunchedEffect(request.id, request.requestBody?.prettyPrintedText) { + if (request.requestBody?.prettyPrintedText != null && !requestBodyPrettyTouched) { + latestOnRequestBodyPrettyAvailable() + } + } + + LaunchedEffect(request.id, request.responseBody?.prettyPrintedText) { + if (request.responseBody?.prettyPrintedText != null && !responseBodyPrettyTouched) { + latestOnResponseBodyPrettyAvailable() + } + } if (didCopyAllEvents) { LaunchedEffect(request.id, didCopyAllEvents) { delay(1_000) - didCopyAllEvents = false + latestOnClearCopyAllEvents() } } } @@ -345,6 +390,9 @@ private fun LazyListScope.bodySectionItems( keyPrefix = "$keyPrefix:payload", ) } + item(key = "$keyPrefix:bottom-gap") { + Spacer(modifier = Modifier.size(Spacings.md)) + } } } diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/WebSocketDetailView.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/WebSocketDetailView.kt index 5ab4d77..ff1dbaa 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/WebSocketDetailView.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/WebSocketDetailView.kt @@ -491,21 +491,23 @@ private fun MessageHeaderActions( NetworkInspectorCopyExporter.copyText(displayText) copyToken += 1 } - Row( - horizontalArrangement = Arrangement.spacedBy(Spacings.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - if (prettyText != null) { - InspectorInlineTextToggle( - label = if (pretty) "PRETTY" else "RAW", - onClick = onPrettyToggle, - ) - } - if (displayText.isNotEmpty()) { - InspectorInlineCopyButton( - isCopied = didCopy, - onCopy = onCopy, - ) + DisableSelection { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacings.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (prettyText != null) { + InspectorInlineTextToggle( + label = if (pretty) "PRETTY" else "RAW", + onClick = onPrettyToggle, + ) + } + if (displayText.isNotEmpty()) { + InspectorInlineCopyButton( + isCopied = didCopy, + onCopy = onCopy, + ) + } } } } diff --git a/snapo-link-android/network-okhttp3/src/main/java/com/openai/snapo/network/okhttp3/SnapOOkHttpInterceptor.kt b/snapo-link-android/network-okhttp3/src/main/java/com/openai/snapo/network/okhttp3/SnapOOkHttpInterceptor.kt index 3b3145e..0adcdb6 100644 --- a/snapo-link-android/network-okhttp3/src/main/java/com/openai/snapo/network/okhttp3/SnapOOkHttpInterceptor.kt +++ b/snapo-link-android/network-okhttp3/src/main/java/com/openai/snapo/network/okhttp3/SnapOOkHttpInterceptor.kt @@ -15,10 +15,14 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import okhttp3.Interceptor import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody import okio.Buffer +import okio.BufferedSink +import okio.ForwardingSink +import okio.buffer import java.io.Closeable import java.io.IOException import java.nio.charset.Charset @@ -44,8 +48,35 @@ class SnapOOkHttpInterceptor @JvmOverloads constructor( ) val request = chain.request() - publishRequest(context, request) + val requestBody = request.body + if (requestBody != null && requestBody.isOneShot()) { + val capturingBody = CapturingRequestBody(requestBody, textBodyMaxBytes) + val requestWithCapture = request.newBuilder() + .method(request.method, capturingBody) + .build() + var didPublish = false + fun publishOnce() { + if (didPublish) return + didPublish = true + publishRequest( + context = context, + request = requestWithCapture, + capturedBody = capturingBody.snapshot(), + skipFallback = true, + ) + } + + return runCatching { + val response = chain.proceed(requestWithCapture) + publishOnce() + handleResponse(context, response) + }.onFailure { error -> + publishOnce() + handleFailure(context, error) + }.getOrThrow() + } + publishRequest(context, request) return runCatching { val response = chain.proceed(request) handleResponse(context, response) @@ -58,15 +89,25 @@ class SnapOOkHttpInterceptor @JvmOverloads constructor( scope.cancel() } - private fun publishRequest(context: InterceptContext, request: Request) { - val requestBody = request.captureBody(textBodyMaxBytes) + private fun publishRequest( + context: InterceptContext, + request: Request, + capturedBody: RequestBodyCapture? = null, + skipFallback: Boolean = false, + ) { + val requestBody = if (skipFallback) capturedBody else capturedBody ?: request.captureBody(textBodyMaxBytes) + val contentType = resolveRequestContentType(requestBody?.contentType, request) publish { var encoding: String? = null val encodedBody: String? = when { requestBody == null -> null - requestBody.contentType.isTextLike() -> { - val charset = requestBody.contentType.resolveCharset() + contentType.isMultipartFormData() -> { + formatMultipartBody(requestBody.body, contentType) + } + + contentType.isTextLike() -> { + val charset = contentType.resolveCharset() String(requestBody.body, charset) } @@ -214,6 +255,67 @@ internal data class RequestBodyCapture( val truncatedBytes: Long, ) +private class RequestBodyCaptureBuffer(private val maxBytes: Int) { + private val buffer = Buffer() + private var totalBytes: Long = 0 + + fun append(source: Buffer, byteCount: Long) { + if (byteCount <= 0L) return + totalBytes += byteCount + if (maxBytes <= 0) return + val remaining = maxBytes.toLong() - buffer.size + if (remaining <= 0L) return + val toCopy = minOf(byteCount, remaining) + source.copyTo(buffer, 0, toCopy) + } + + fun snapshot(contentType: MediaType?): RequestBodyCapture? { + if (maxBytes <= 0) return null + val captured = buffer.readByteArray() + val truncatedBytes = (totalBytes - captured.size.toLong()).coerceAtLeast(0L) + return RequestBodyCapture( + contentType = contentType, + body = captured, + truncatedBytes = truncatedBytes, + ) + } +} + +private class CapturingRequestBody( + private val delegate: okhttp3.RequestBody, + private val maxBytes: Int, +) : okhttp3.RequestBody() { + @Volatile + private var captured: RequestBodyCapture? = null + + fun snapshot(): RequestBodyCapture? = captured + + override fun contentType(): MediaType? = delegate.contentType() + + override fun contentLength(): Long = delegate.contentLength() + + override fun isOneShot(): Boolean = delegate.isOneShot() + + override fun isDuplex(): Boolean = delegate.isDuplex() + + override fun writeTo(sink: BufferedSink) { + val capture = RequestBodyCaptureBuffer(maxBytes) + val capturingSink = object : ForwardingSink(sink) { + override fun write(source: Buffer, byteCount: Long) { + capture.append(source, byteCount) + super.write(source, byteCount) + } + } + val buffered = capturingSink.buffer() + try { + delegate.writeTo(buffered) + buffered.flush() + } finally { + captured = capture.snapshot(contentType()) + } + } +} + private fun ResponseBody?.safeContentLength(): Long? = this?.let { try { it.contentLength().takeIf { len -> len >= 0L } @@ -255,6 +357,12 @@ private fun MediaType?.isTextLike(): Boolean = when { ).any(subtype.lowercase()::contains) } +private fun MediaType?.isMultipartFormData(): Boolean { + val mediaType = this ?: return false + return mediaType.type.equals("multipart", ignoreCase = true) && + mediaType.subtype.equals("form-data", ignoreCase = true) +} + private fun MediaType?.isEventStream(): Boolean { val mediaType = this ?: return false return mediaType.type.equals("text", ignoreCase = true) && @@ -388,6 +496,259 @@ private fun MediaType?.resolveCharset(): Charset { } } +private fun resolveRequestContentType( + captureContentType: MediaType?, + request: Request, +): MediaType? { + if (captureContentType != null) return captureContentType + val headerValue = request.header("Content-Type") ?: return null + return headerValue.toMediaTypeOrNull() +} + +private fun formatMultipartBody(bodyBytes: ByteArray, contentType: MediaType?): String { + val boundary = extractMultipartBoundary(contentType) ?: return String(bodyBytes, Charsets.UTF_8) + val sections = splitMultipartSections(bodyBytes, boundary) + val parts = sections.mapNotNull(::parseMultipartSection) + if (parts.isEmpty()) return String(bodyBytes, Charsets.UTF_8) + return renderMultipartParts(parts) +} + +private fun extractMultipartBoundary(contentType: MediaType?): String? { + val boundary = contentType?.parameter("boundary")?.trim()?.trim('"') ?: return null + return boundary.takeIf { it.isNotEmpty() } +} + +private data class MultipartPart( + val name: String?, + val filename: String?, + val contentType: String?, + val bodyBytes: ByteArray, + val isText: Boolean, + val charset: Charset?, +) + +private fun parseMultipartSection(sectionBytes: ByteArray): MultipartPart? { + val trimmed = sectionBytes.trimMultipartSection() ?: return null + val (headerBytes, bodyBytes) = splitMultipartBytes(trimmed) + val headers = parseMultipartHeaders(headerBytes) + val disposition = headers["content-disposition"] + val name = disposition?.let { parseHeaderParam(it, "name") } + val filename = disposition?.let { parseHeaderParam(it, "filename") } + val partType = headers["content-type"] + val isText = isTextMultipartPart(partType, filename) + val charset = parseCharset(partType) + + return MultipartPart( + name = name, + filename = filename, + contentType = partType, + bodyBytes = bodyBytes, + isText = isText, + charset = charset, + ) +} + +private fun splitMultipartSections(bodyBytes: ByteArray, boundary: String): List { + val marker = ("--$boundary").toByteArray(Charsets.UTF_8) + val indices = findAllMarkers(bodyBytes, marker) + if (indices.isEmpty()) return emptyList() + val sections = ArrayList(indices.size) + for (index in indices.indices) { + val markerIndex = indices[index] + if (startsWith(bodyBytes, markerIndex + marker.size, byteArrayOf('-'.code.toByte(), '-'.code.toByte()))) { + break + } + val sectionStart = skipLineBreaks(bodyBytes, markerIndex + marker.size) + val sectionEnd = resolveSectionEnd(bodyBytes, indices, index) + if (sectionStart < sectionEnd) { + sections.add(bodyBytes.copyOfRange(sectionStart, sectionEnd)) + } + } + return sections +} + +private fun resolveSectionEnd( + bodyBytes: ByteArray, + indices: List, + index: Int, +): Int { + val nextIndex = indices.getOrNull(index + 1) ?: bodyBytes.size + var end = nextIndex + if (end >= 2 && bodyBytes[end - 2] == '\r'.code.toByte() && bodyBytes[end - 1] == '\n'.code.toByte()) { + end -= 2 + } + return end +} + +private fun findAllMarkers(source: ByteArray, marker: ByteArray): List { + val indices = ArrayList() + var index = indexOfSequence(source, marker, 0) + while (index >= 0) { + indices.add(index) + index = indexOfSequence(source, marker, index + marker.size) + } + return indices +} + +private fun indexOfSequence(source: ByteArray, pattern: ByteArray, startIndex: Int): Int { + if (pattern.isEmpty() || source.size < pattern.size) return -1 + val maxIndex = source.size - pattern.size + var index = startIndex.coerceAtLeast(0) + while (index <= maxIndex) { + if (matchesAt(source, pattern, index)) return index + index++ + } + return -1 +} + +private fun matchesAt(source: ByteArray, pattern: ByteArray, index: Int): Boolean { + for (offset in pattern.indices) { + if (source[index + offset] != pattern[offset]) return false + } + return true +} + +private fun skipLineBreaks(source: ByteArray, startIndex: Int): Int { + var index = startIndex + if (startsWith(source, index, byteArrayOf('\r'.code.toByte(), '\n'.code.toByte()))) { + index += 2 + } else if (startsWith(source, index, byteArrayOf('\n'.code.toByte()))) { + index += 1 + } + return index +} + +private fun startsWith(source: ByteArray, startIndex: Int, prefix: ByteArray): Boolean { + if (startIndex < 0 || startIndex + prefix.size > source.size) return false + for (i in prefix.indices) { + if (source[startIndex + i] != prefix[i]) return false + } + return true +} + +private fun splitMultipartBytes(section: ByteArray): Pair { + val crlfcrlf = byteArrayOf( + '\r'.code.toByte(), + '\n'.code.toByte(), + '\r'.code.toByte(), + '\n'.code.toByte(), + ) + val lfLf = byteArrayOf('\n'.code.toByte(), '\n'.code.toByte()) + val crlfIndex = indexOfSequence(section, crlfcrlf, 0) + if (crlfIndex >= 0) { + return section.copyOfRange(0, crlfIndex) to section.copyOfRange(crlfIndex + crlfcrlf.size, section.size) + } + val lfIndex = indexOfSequence(section, lfLf, 0) + if (lfIndex >= 0) { + return section.copyOfRange(0, lfIndex) to section.copyOfRange(lfIndex + lfLf.size, section.size) + } + return section to ByteArray(0) +} + +private fun renderMultipartParts(parts: List): String { + val rendered = StringBuilder() + parts.forEach { part -> + rendered.append("Part") + if (part.name != null) rendered.append(" name=\"").append(part.name).append("\"") + if (part.filename != null) rendered.append(" filename=\"").append(part.filename).append("\"") + if (part.contentType != null) rendered.append(" (").append(part.contentType).append(")") + rendered.append("\n") + + if (part.isText) { + rendered.append(String(part.bodyBytes, part.charset ?: Charsets.UTF_8)) + } else { + rendered.append(encodeToString(part.bodyBytes, NO_WRAP)) + } + rendered.append("\n\n") + } + return rendered.toString().trimEnd() +} + +private fun parseMultipartHeaders(headerBytes: ByteArray): Map { + if (headerBytes.isEmpty()) return emptyMap() + return parseMultipartHeaders(headerBytes.toString(Charsets.ISO_8859_1)) +} + +private fun parseMultipartHeaders(headerBlock: String): Map { + if (headerBlock.isBlank()) return emptyMap() + return headerBlock + .lines() + .mapNotNull { line -> + val idx = line.indexOf(':') + if (idx <= 0) return@mapNotNull null + val key = line.substring(0, idx).trim().lowercase() + val value = line.substring(idx + 1).trim() + key to value + } + .toMap() +} + +private fun ByteArray.trimMultipartSection(): ByteArray? { + if (isEmpty()) return null + var start = 0 + var end = size + while (start < end && this[start].toInt() == '\n'.code) start++ + while (start < end && this[start].toInt() == '\r'.code) start++ + while (end > start && this[end - 1].toInt() == '\n'.code) end-- + while (end > start && this[end - 1].toInt() == '\r'.code) end-- + if (start >= end) return null + return copyOfRange(start, end) +} + +private fun isTextMultipartPart(contentType: String?, filename: String?): Boolean { + return when { + contentType != null -> contentType.isTextLikeHeader() + filename != null -> false + else -> true + } +} + +private fun parseCharset(contentType: String?): Charset? { + val value = contentType ?: return null + val charset = value.split(';') + .firstOrNull { it.trim().startsWith("charset=", ignoreCase = true) } + ?.substringAfter('=') + ?.trim() + ?.trim('"') + ?.takeIf { it.isNotEmpty() } + ?: return null + return try { + Charset.forName(charset) + } catch (_: IllegalArgumentException) { + null + } +} + +private fun parseHeaderParam(headerValue: String, paramName: String): String? { + return headerValue + .split(';') + .asSequence() + .map { it.trim() } + .mapNotNull { segment -> + val idx = segment.indexOf('=') + if (idx <= 0) return@mapNotNull null + val key = segment.substring(0, idx).trim() + val value = segment.substring(idx + 1).trim().trim('"') + key to value + } + .firstOrNull { (key, _) -> key.equals(paramName, ignoreCase = true) } + ?.second +} + +private fun String.isTextLikeHeader(): Boolean { + val value = lowercase() + return value.startsWith("text/") || + listOf( + "application/json", + "application/xml", + "application/x-www-form-urlencoded", + "application/graphql", + "application/javascript", + ).any { value.contains(it) } || + listOf("json", "xml", "html", "javascript", "form", "graphql", "plain", "csv", "yaml") + .any { value.contains(it) } +} + internal fun nanosToMillis(deltaNs: Long): Long? { if (deltaNs <= 0L) return null return TimeUnit.NANOSECONDS.toMillis(deltaNs) diff --git a/snapo-link-android/samples/demo-httpurlconnection/src/main/java/com/openai/snapo/demo/httpurlconnection/MainActivity.kt b/snapo-link-android/samples/demo-httpurlconnection/src/main/java/com/openai/snapo/demo/httpurlconnection/MainActivity.kt index f32d7c9..17d23dd 100644 --- a/snapo-link-android/samples/demo-httpurlconnection/src/main/java/com/openai/snapo/demo/httpurlconnection/MainActivity.kt +++ b/snapo-link-android/samples/demo-httpurlconnection/src/main/java/com/openai/snapo/demo/httpurlconnection/MainActivity.kt @@ -30,36 +30,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { - MaterialTheme { - val scope = rememberCoroutineScope() - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - DemoContent( - onNetworkRequestClick = { - scope.launch { - withContext(Dispatchers.IO) { - val connection = interceptor.open( - URL("https://publicobject.com/helloworld.txt") - ) - try { - connection.requestMethod = "GET" - connection.addRequestProperty("Duplicated", "11111111") - connection.addRequestProperty("Duplicated", "2222222") - connection.connect() - connection.inputStream.bufferedReader().use { reader -> - println(reader.readText()) - } - } finally { - connection.disconnect() - } - } - } - }, - modifier = Modifier.padding(innerPadding), - ) - } - } - } + setContent { DemoScreen(interceptor = interceptor) } } override fun onDestroy() { @@ -68,9 +39,83 @@ class MainActivity : ComponentActivity() { } } +@Composable +private fun DemoScreen(interceptor: SnapOHttpUrlInterceptor) { + MaterialTheme { + val scope = rememberCoroutineScope() + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + DemoContent( + onNetworkRequestClick = { + scope.launch { performGetRequest(interceptor) } + }, + onPostRequestClick = { + scope.launch { performPostRequest(interceptor) } + }, + modifier = Modifier.padding(innerPadding), + ) + } + } +} + +private suspend fun performGetRequest(interceptor: SnapOHttpUrlInterceptor) { + withContext(Dispatchers.IO) { + val connection = interceptor.open( + URL("https://publicobject.com/helloworld.txt") + ) + try { + connection.requestMethod = "GET" + connection.addRequestProperty("Duplicated", "11111111") + connection.addRequestProperty("Duplicated", "2222222") + connection.connect() + connection.inputStream.bufferedReader().use { reader -> + println(reader.readText()) + } + } finally { + connection.disconnect() + } + } +} + +private suspend fun performPostRequest(interceptor: SnapOHttpUrlInterceptor) { + withContext(Dispatchers.IO) { + val connection = interceptor.open( + URL("https://postman-echo.com/post") + ) + try { + connection.requestMethod = "POST" + connection.doOutput = true + connection.setRequestProperty( + "Content-Type", + "application/json; charset=utf-8", + ) + connection.setRequestProperty("X-SnapO-Demo", "httpurl-post") + val payload = """ + { + "message": "Hello from Snap-O!", + "source": "httpurlconnection-demo" + } + """.trimIndent() + connection.outputStream.use { output -> + output.write(payload.toByteArray(Charsets.UTF_8)) + } + val stream = if (connection.responseCode >= 400) { + connection.errorStream + } else { + connection.inputStream + } + stream?.bufferedReader()?.use { reader -> + println(reader.readText()) + } + } finally { + connection.disconnect() + } + } +} + @Composable private fun DemoContent( onNetworkRequestClick: () -> Unit, + onPostRequestClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -80,6 +125,9 @@ private fun DemoContent( Button(onClick = onNetworkRequestClick) { Text("Network Request") } + Button(onClick = onPostRequestClick) { + Text("POST Request") + } } } @@ -87,6 +135,9 @@ private fun DemoContent( @Composable private fun DemoPreview() { MaterialTheme { - DemoContent(onNetworkRequestClick = {}) + DemoContent( + onNetworkRequestClick = {}, + onPostRequestClick = {}, + ) } } diff --git a/snapo-link-android/samples/demo-ktor-okhttp/src/main/java/com/openai/snapo/demo/ktor/MainActivity.kt b/snapo-link-android/samples/demo-ktor-okhttp/src/main/java/com/openai/snapo/demo/ktor/MainActivity.kt index cb8dbea..58cce34 100644 --- a/snapo-link-android/samples/demo-ktor-okhttp/src/main/java/com/openai/snapo/demo/ktor/MainActivity.kt +++ b/snapo-link-android/samples/demo-ktor-okhttp/src/main/java/com/openai/snapo/demo/ktor/MainActivity.kt @@ -23,8 +23,15 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.webSocket +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData import io.ktor.client.request.get import io.ktor.client.request.headers +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.contentType import io.ktor.websocket.CloseReason import io.ktor.websocket.Frame import io.ktor.websocket.close @@ -51,44 +58,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { - MaterialTheme { - val scope = rememberCoroutineScope() - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - DemoContent( - onNetworkRequestClick = { - scope.launch { - httpClient.get("https://publicobject.com/helloworld.txt") { - headers { - append("Duplicated", "1111111") - append("Duplicated", "2222222") - } - } - } - }, - onWebSocketDemoClick = { - scope.launch { - httpClient.webSocket(urlString = "wss://echo.websocket.org") { - send("Hello from Snap-O!") - for (frame in incoming) { - if (frame is Frame.Text) { - close( - CloseReason( - CloseReason.Codes.NORMAL, - "Closing after echo", - ) - ) - break - } - } - } - } - }, - modifier = Modifier.padding(innerPadding), - ) - } - } - } + setContent { DemoScreen(httpClient = httpClient) } } override fun onDestroy() { @@ -97,9 +67,89 @@ class MainActivity : ComponentActivity() { } } +@Composable +private fun DemoScreen(httpClient: HttpClient) { + MaterialTheme { + val scope = rememberCoroutineScope() + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + DemoContent( + onNetworkRequestClick = { + scope.launch { performGetRequest(httpClient) } + }, + onPostRequestClick = { + scope.launch { performPostRequest(httpClient) } + }, + onFormRequestClick = { + scope.launch { performFormRequest(httpClient) } + }, + onWebSocketDemoClick = { + scope.launch { performWebSocketDemo(httpClient) } + }, + modifier = Modifier.padding(innerPadding), + ) + } + } +} + +private suspend fun performGetRequest(httpClient: HttpClient) { + httpClient.get("https://publicobject.com/helloworld.txt") { + headers { + append("Duplicated", "1111111") + append("Duplicated", "2222222") + } + } +} + +private suspend fun performPostRequest(httpClient: HttpClient) { + httpClient.post("https://postman-echo.com/post") { + contentType(ContentType.Application.Json) + headers { append("X-SnapO-Demo", "ktor-post") } + setBody( + """ + { + "message": "Hello from Snap-O!", + "source": "ktor-okhttp-demo" + } + """.trimIndent() + ) + } +} + +private suspend fun performFormRequest(httpClient: HttpClient) { + httpClient.submitFormWithBinaryData( + url = "https://postman-echo.com/post", + formData = formData { + append("field1", "example payload") + append("field2", """{"test":true,"value":123}""") + }, + ) { + method = HttpMethod.Post + url { parameters.append("param1", "example") } + } +} + +private suspend fun performWebSocketDemo(httpClient: HttpClient) { + httpClient.webSocket(urlString = "wss://echo.websocket.org") { + send("Hello from Snap-O!") + for (frame in incoming) { + if (frame is Frame.Text) { + close( + CloseReason( + CloseReason.Codes.NORMAL, + "Closing after echo", + ) + ) + break + } + } + } +} + @Composable private fun DemoContent( onNetworkRequestClick: () -> Unit, + onPostRequestClick: () -> Unit, + onFormRequestClick: () -> Unit, onWebSocketDemoClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -110,6 +160,12 @@ private fun DemoContent( Button(onClick = onNetworkRequestClick) { Text("Network Request") } + Button(onClick = onPostRequestClick) { + Text("POST Request") + } + Button(onClick = onFormRequestClick) { + Text("Form POST") + } Button(onClick = onWebSocketDemoClick) { Text("WebSocket Echo") } @@ -122,6 +178,8 @@ private fun DemoPreview() { MaterialTheme { DemoContent( onNetworkRequestClick = {}, + onPostRequestClick = {}, + onFormRequestClick = {}, onWebSocketDemoClick = {}, ) } diff --git a/snapo-link-android/samples/demo-okhttp/src/main/java/com/openai/snapo/demo/MainActivity.kt b/snapo-link-android/samples/demo-okhttp/src/main/java/com/openai/snapo/demo/MainActivity.kt index f5fe1c1..3d0dd6d 100644 --- a/snapo-link-android/samples/demo-okhttp/src/main/java/com/openai/snapo/demo/MainActivity.kt +++ b/snapo-link-android/samples/demo-okhttp/src/main/java/com/openai/snapo/demo/MainActivity.kt @@ -22,8 +22,10 @@ import com.openai.snapo.network.okhttp3.withSnapOInterceptor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener @@ -64,6 +66,28 @@ class MainActivity : ComponentActivity() { } } }, + onPostRequestClick = { + val mediaType = "application/json; charset=utf-8".toMediaType() + val body = """ + { + "message": "Hello from Snap-O!", + "source": "okhttp-demo" + } + """.trimIndent().toRequestBody(mediaType) + val request = Request.Builder() + .url("https://postman-echo.com/post") + .header("X-SnapO-Demo", "okhttp-post") + .post(body) + .build() + val call = client.newCall(request) + scope.launch { + call.executeAsync().use { response -> + withContext(Dispatchers.IO) { + println(response.body.string()) + } + } + } + }, onWebSocketDemoClick = { startWebSocketDemo() }, modifier = Modifier.padding(innerPadding) ) @@ -105,6 +129,7 @@ class MainActivity : ComponentActivity() { @Composable fun Greeting( onNetworkRequestClick: () -> Unit, + onPostRequestClick: () -> Unit, onWebSocketDemoClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -115,6 +140,9 @@ fun Greeting( Button(onClick = onNetworkRequestClick) { Text("Network Request") } + Button(onClick = onPostRequestClick) { + Text("POST Request") + } Button(onClick = onWebSocketDemoClick) { Text("WebSocket Echo") } @@ -127,6 +155,7 @@ private fun GreetingPreview() { MaterialTheme { Greeting( onNetworkRequestClick = {}, + onPostRequestClick = {}, onWebSocketDemoClick = {}, ) }