From 97841da8e26d358f79e6bd19e75bc5b2936b1edf Mon Sep 17 00:00:00 2001 From: dpiet-oai <326640584+dpiet-oai@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:05:14 +0000 Subject: [PATCH] feat(core): add HTTP adapter headers and body accessors Castiron-Internal-PR: https://github.com/openai/openai-java-internal/pull/294 Castiron-Source-SHA: 8f83dec63cbd69c095ca0fdd359aefea7ec4a09c Castiron-Public-Base-SHA: 623d44830879212d06e0f93d709dbcef43fec540 --- .castiron.stats.yml | 2 +- .../kotlin/com/openai/core/http/Headers.kt | 14 + .../com/openai/core/http/HttpRequestBodies.kt | 46 +++ .../com/openai/core/http/HttpRequestBody.kt | 31 ++ .../com/openai/core/http/LoggingHttpClient.kt | 58 ++++ .../openai/core/http/HeadersMapViewTest.kt | 45 +++ .../core/http/HttpRequestBodyContentTest.kt | 296 ++++++++++++++++++ .../core/http/HttpRequestBodyJavaTest.kt | 88 ++++++ .../openai/core/http/LoggingHttpClientTest.kt | 73 +++++ 9 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 openai-java-core/src/test/kotlin/com/openai/core/http/HeadersMapViewTest.kt create mode 100644 openai-java-core/src/test/kotlin/com/openai/core/http/HttpRequestBodyContentTest.kt create mode 100644 openai-java-core/src/test/kotlin/com/openai/core/http/HttpRequestBodyJavaTest.kt diff --git a/.castiron.stats.yml b/.castiron.stats.yml index fbdcb9b3d..ead5cb6d5 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -5,4 +5,4 @@ openapi_transformed_spec_hash: aec25c66453cfdadd072d47e262b4c42 config_hash: 0adc48b9b2b37cb31efd58e02e66aaa8 codegen_sha: 7d1ad3e0d53fd0742357551b1713d6c3f43fd61f codegen_hash: 9d4e0ad7cff3d5010866de62216e9d9d0e580556771cbf5b2fc5dc5f5956757b -public_codegen_sha: 5136b8ece6dfb3b49a77e0fef1bfa105c2d90d87 +public_codegen_sha: c52d64182f1de943bb6d6725ad83a164d86a8cf1 diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/Headers.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/Headers.kt index a04470c71..04e5ed379 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/Headers.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/Headers.kt @@ -21,8 +21,22 @@ private constructor( fun names(): Set = map.keys + /** + * Returns all values associated with [name], or an empty list if the header is absent. Names + * returned by [names] always have at least one value. + */ fun values(name: String): List = map[name].orEmpty() + /** + * Returns an immutable map of header names to their values, with case-insensitive lookup. Value + * lists are immutable and preserve insertion order. + * + * ```java + * Map> headers = request.headers().asMap(); + * ``` + */ + fun asMap(): Map> = map + fun toBuilder(): Builder = Builder().putAll(map) companion object { diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestBodies.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestBodies.kt index 4f47010fc..a2ab3b9bd 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestBodies.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestBodies.kt @@ -11,8 +11,10 @@ import com.openai.core.multipartValueToTree import com.openai.core.toImmutable import com.openai.errors.OpenAIInvalidDataException import java.io.ByteArrayInputStream +import java.io.FilterInputStream import java.io.InputStream import java.io.OutputStream +import java.io.SequenceInputStream import java.util.Collections import java.util.IdentityHashMap import java.util.UUID @@ -28,6 +30,8 @@ internal inline fun json(jsonMapper: JsonMapper, value: T): HttpRequ override fun writeTo(outputStream: OutputStream) = outputStream.write(bytes) + override fun content(): InputStream = bytes.inputStream() + override fun contentType(): String = "application/json" override fun contentLength(): Long = bytes.size.toLong() @@ -96,6 +100,8 @@ private fun buildMultipartBody( outputStream.write(byteArray) } + override fun content(): InputStream = byteArray.inputStream() + override fun contentType(): String = contentType override fun contentLength(): Long = byteArray.size.toLong() @@ -111,6 +117,13 @@ private fun buildMultipartBody( bytes.copyTo(outputStream) } + override fun content(): InputStream = + object : FilterInputStream(bytes) { + // SequenceInputStream closes parts at EOF. A later + // part may share this upload; the aggregate owns it. + override fun close() {} + } + override fun contentType(): String = contentType override fun contentLength(): Long = -1L @@ -225,6 +238,39 @@ private constructor( outputStream.write(CRLF) } + // This must remain in sync with `writeTo`. + override fun content(): InputStream { + val streams = mutableListOf() + + parts.forEach { part -> + streams.add(DASHDASH.inputStream()) + streams.add(boundaryBytes.inputStream()) + streams.add(CRLF.inputStream()) + streams.add(CONTENT_DISPOSITION.inputStream()) + streams.add(part.contentDisposition.toByteArray().inputStream()) + streams.add(CRLF.inputStream()) + streams.add(CONTENT_TYPE.inputStream()) + streams.add(part.contentType.toByteArray().inputStream()) + streams.add(CRLF.inputStream()) + streams.add(CRLF.inputStream()) + streams.add(part.body.content()) + streams.add(CRLF.inputStream()) + } + + streams.add(DASHDASH.inputStream()) + streams.add(boundaryBytes.inputStream()) + streams.add(DASHDASH.inputStream()) + streams.add(CRLF.inputStream()) + + return object : SequenceInputStream(Collections.enumeration(streams)) { + override fun close() { + // Release uploads through the same close-once tracking used by the body and + // cancellation, including every remaining upload if a close throws. + AutoCloseable { closeAll(parts) { it.body.close() } }.use { super.close() } + } + } + } + override fun contentType(): String = contentType // This must remain in sync with `writeTo`. diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestBody.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestBody.kt index f44a762ca..5d3ff0e58 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestBody.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestBody.kt @@ -1,5 +1,7 @@ package com.openai.core.http +import java.io.ByteArrayOutputStream +import java.io.InputStream import java.io.OutputStream import java.lang.AutoCloseable @@ -7,6 +9,35 @@ interface HttpRequestBody : AutoCloseable { fun writeTo(outputStream: OutputStream) + /** + * Returns the request body content as an input stream. + * + * The stream produces the same bytes as [writeTo]. Close the returned stream after each + * attempt, and close this body after the final attempt, including on failure or cancellation. + * Closing a stream may release its underlying upload resources. Always close the body too, even + * if opening or reading the stream fails. + * + * If [repeatable] is true, each call returns a fresh stream over equivalent content. Otherwise, + * consume the body only once, using either this method or [writeTo]. Concurrent consumption is + * not supported. + * + * The default implementation buffers the bytes produced by [writeTo] so existing third-party + * implementations remain compatible. Implementations backed by an existing byte array or stream + * should override this method to avoid buffering. + * + * ```java + * try (InputStream content = body.content()) { + * // Pass content to the HTTP transport and wait for consumption to finish. + * } + * // Close body after the final transport attempt. + * ``` + */ + fun content(): InputStream { + val outputStream = ByteArrayOutputStream() + writeTo(outputStream) + return outputStream.toByteArray().inputStream() + } + fun contentType(): String? fun contentLength(): Long diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/LoggingHttpClient.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/LoggingHttpClient.kt index 22b1fbe1a..74666319d 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/LoggingHttpClient.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/LoggingHttpClient.kt @@ -284,6 +284,8 @@ private class LoggingHttpRequestBody( System.err.println() } + override fun content(): InputStream = LoggingRequestInputStream(method, body.content(), charset) + override fun contentType(): String? = body.contentType() override fun contentLength(): Long = body.contentLength() @@ -293,6 +295,62 @@ private class LoggingHttpRequestBody( override fun close() = body.close() } +private class LoggingRequestInputStream( + private val method: HttpMethod, + private val inputStream: InputStream, + charset: Charset?, +) : InputStream() { + + private var isDone = false + private var isClosed = false + private val buffer = LoggingBuffer(charset) + + override fun read(): Int { + if (isDone) return -1 + val byte = inputStream.read() + if (byte == -1) { + markDone() + return -1 + } + buffer.write(byte) + return byte + } + + override fun read(bytes: ByteArray, offset: Int, length: Int): Int { + if (offset < 0 || length < 0 || offset > bytes.size - length) { + throw IndexOutOfBoundsException() + } + if (length == 0) return 0 + if (isDone) return -1 + val count = inputStream.read(bytes, offset, length) + if (count == -1) { + markDone() + return -1 + } + for (index in offset until offset + count) { + buffer.write(bytes[index].toInt() and 0xFF) + } + return count + } + + override fun close() { + if (isClosed) return + isClosed = true + inputStream.use { if (!isDone) markDone(closedEarly = true) } + } + + private fun markDone(closedEarly: Boolean = false) { + if (isDone) return + isDone = true + buffer.flush() + val suffix = if (closedEarly) ", closed early" else "" + System.err.println( + "--> END " + method + " (" + buffer.writeCount() + "-byte body" + suffix + ")" + ) + System.err.println() + } +} + /** * An [OutputStream] wrapper that delegates to [outputStream] while also logging bytes line by line * as it's written to. diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/HeadersMapViewTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/HeadersMapViewTest.kt new file mode 100644 index 000000000..409f1b8dc --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/HeadersMapViewTest.kt @@ -0,0 +1,45 @@ +package com.openai.core.http + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +internal class HeadersMapViewTest { + @Test + fun preservesNamesAndOrderedValuesWithCaseInsensitiveLookup() { + val headers = Headers.builder().put("X-Test", "one").put("x-test", "two").build() + val map = headers.asMap() + + assertThat(map).hasSize(1) + assertThat(map.keys).containsExactly("X-Test") + assertThat(map["X-TEST"]).containsExactly("one", "two") + assertThat(map.containsKey("x-test")).isTrue() + assertThat(map["missing"]).isNull() + assertThat(headers.values("missing")).isEmpty() + assertThat(Headers.builder().build().asMap()).isEmpty() + } + + @Test + fun rejectsMapAndValueMutations() { + val map = Headers.builder().put("X-Test", "one").build().asMap() as MutableMap + + assertThatThrownBy { map["Other"] = listOf("value") } + .isInstanceOf(UnsupportedOperationException::class.java) + assertThatThrownBy { (map.getValue("x-test") as MutableList).add("two") } + .isInstanceOf(UnsupportedOperationException::class.java) + assertThatThrownBy { map.entries.first().setValue(listOf("two")) } + .isInstanceOf(UnsupportedOperationException::class.java) + } + + @Test + fun remainsUnchangedWhenBuildersAreModified() { + val builder = Headers.builder().put("X-Test", "one") + val headers = builder.build() + val map = headers.asMap() + + builder.put("x-test", "two") + headers.toBuilder().replace("X-Test", "three").build() + + assertThat(map["x-test"]).containsExactly("one") + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/HttpRequestBodyContentTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/HttpRequestBodyContentTest.kt new file mode 100644 index 000000000..0b0ce77fb --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/HttpRequestBodyContentTest.kt @@ -0,0 +1,296 @@ +package com.openai.core.http + +import com.openai.core.MultipartField +import com.openai.core.RequestOptions +import com.openai.core.Sleeper +import com.openai.core.jsonMapper +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class HttpRequestBodyContentTest { + + @Test + fun jsonContent_canBeReadAgainAfterClosingAnEarlierStream() { + val body = json(jsonMapper(), mapOf("message" to "hello 🌍")) + val expected = ByteArrayOutputStream().also(body::writeTo).toByteArray() + + repeat(2) { body.content().use { assertThat(it.readBytes()).isEqualTo(expected) } } + assertThat(body.repeatable()).isTrue() + assertThat(body.contentLength()).isEqualTo(expected.size.toLong()) + body.close() + } + + @Test + fun multipartContent_doesNotReadUploadsUntilConsumed() { + val upload = RecordingInputStream("file contents") + val body = uploadBody(upload) + val content = body.content() + + assertThat(upload.readCalls).isZero() + assertThat(content.read()).isEqualTo('-'.code) + assertThat(upload.readCalls).isZero() + assertThat(body.repeatable()).isFalse() + assertThat(body.contentLength()).isEqualTo(-1L) + content.close() + body.close() + assertThat(upload.closeCalls).isEqualTo(1) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun multipartContent_closesUploadsOnceOnEarlyCloseOrEof(readToEnd: Boolean) { + val first = RecordingInputStream("one") + val second = RecordingInputStream("two") + val body = uploadBody(first, second) + val content = body.content() + + if (readToEnd) + assertThat(content.readBytes().toString(Charsets.UTF_8)).contains("one", "two") + else content.read() + content.close() + content.close() + body.close() + body.close() + + assertThat(listOf(first.closeCalls, second.closeCalls)).containsExactly(1, 1) + if (!readToEnd) assertThat(listOf(first.readCalls, second.readCalls)).containsExactly(0, 0) + } + + @Test + fun multipartContent_closesSharedUploadOnce() { + val upload = RecordingInputStream("shared") + val body = uploadBody(upload, upload) + + body.content().close() + body.close() + + assertThat(upload.closeCalls).isEqualTo(1) + } + + @Test + fun multipartContent_matchesWriteToWithASharedCloseSensitiveUpload() { + val pushUpload = "shared".byteInputStream().buffered() + val pullUpload = "shared".byteInputStream().buffered() + uploadBody(pushUpload, pushUpload).use { pushBody -> + uploadBody(pullUpload, pullUpload).use { pullBody -> + val expected = + ByteArrayOutputStream() + .also(pushBody::writeTo) + .toString("UTF-8") + .replace( + pushBody.contentType()!!.substringAfter("boundary="), + pullBody.contentType()!!.substringAfter("boundary="), + ) + + pullBody.content().use { + assertThat(it.readBytes().toString(Charsets.UTF_8)).isEqualTo(expected) + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun multipartContent_attemptsEveryCloseWhenCleanupThrows(sharedFailure: Boolean) { + val firstFailure = IOException("first close") + val secondFailure = if (sharedFailure) firstFailure else IOException("second close") + val first = RecordingInputStream(closeFailure = firstFailure) + val second = RecordingInputStream(closeFailure = secondFailure) + val third = RecordingInputStream() + val body = uploadBody(first, second, third) + val content = body.content() + + val failure = catchThrowable { content.close() } + body.close() + content.close() + + assertThat(failure).isSameAs(firstFailure) + if (sharedFailure) assertThat(failure.suppressed).isEmpty() + else assertThat(failure.suppressed).containsExactly(secondFailure) + assertThat(listOf(first.closeCalls, second.closeCalls, third.closeCalls)) + .containsExactly(1, 1, 1) + } + + @Test + fun multipartContent_closeAfterCancellationDoesNotCloseUploadsAgain() { + val upload = RecordingInputStream() + val body = uploadBody(upload) + val content = body.content() + + body.cancelMultipart(java.util.concurrent.CancellationException()) + content.close() + body.close() + + assertThat(upload.closeCalls).isEqualTo(1) + } + + @Test + fun multipartContent_canBeReadAgainAndMixedWithWriteToForRepeatableBodies() { + val body = + multipartFormData( + jsonMapper(), + mapOf( + "field\nname" to + MultipartField.builder() + .value("héllo") + .filename("file\".txt") + .build(), + "binary" to + MultipartField.builder().value(byteArrayOf(0, 1, -1)).build(), + ), + ) + val expected = ByteArrayOutputStream().also(body::writeTo).toByteArray() + + repeat(2) { body.content().use { assertThat(it.readBytes()).isEqualTo(expected) } } + assertThat(ByteArrayOutputStream().also(body::writeTo).toByteArray()).isEqualTo(expected) + assertThat(body.contentLength()).isEqualTo(expected.size.toLong()) + body.close() + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun contentTransport_retriesOnlyRepeatableBodies(repeatable: Boolean) { + val body = + if (repeatable) json(jsonMapper(), mapOf("key" to "value")) + else uploadBody(RecordingInputStream()) + val payloads = mutableListOf() + val transport = mock() + whenever(transport.execute(any(), any())).thenAnswer { invocation -> + val request = invocation.getArgument(0) + request.body!!.content().use { payloads.add(it.readBytes()) } + mock().also { + whenever(it.statusCode()).thenReturn(if (payloads.size == 1) 500 else 200) + whenever(it.headers()).thenReturn(Headers.builder().build()) + } + } + val client = + RetryingHttpClient.builder() + .httpClient(transport) + .sleeper(mock()) + .maxRetries(1) + .build() + val request = + HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl("https://example.com") + .body(body) + .build() + + client.use { it.execute(request, RequestOptions.none()).close() } + body.close() + + assertThat(payloads).hasSize(if (repeatable) 2 else 1) + if (repeatable) assertThat(payloads[1]).isEqualTo(payloads[0]) + } + + private fun uploadBody(vararg inputs: InputStream): HttpRequestBody = + multipartFormData( + jsonMapper(), + inputs + .mapIndexed { index, input -> + "file-$index" to MultipartField.builder().value(input).build() + } + .toMap(), + ) + + // A real stream is needed to exercise partial reads and repeated/throwing close calls. + private class RecordingInputStream( + text: String = "file", + private val closeFailure: IOException? = null, + ) : InputStream() { + private val delegate = text.byteInputStream() + var readCalls = 0 + var closeCalls = 0 + + override fun read(): Int { + readCalls++ + return delegate.read() + } + + override fun close() { + closeCalls++ + closeFailure?.let { throw it } + } + } + + @Test + fun content_defaultsToWriteTo() { + val body = + object : HttpRequestBody { + override fun writeTo(outputStream: OutputStream) { + outputStream.write("body".toByteArray()) + } + + override fun contentType(): String = "text/plain" + + override fun contentLength(): Long = 4L + + override fun repeatable(): Boolean = true + + override fun close() {} + } + + body.content().use { content -> + assertThat(content.readBytes()).isEqualTo("body".toByteArray()) + } + } + + @Test + fun multipartContent_matchesWriteTo() { + val body = + multipartFormData( + jsonMapper(), + mapOf( + "field" to + MultipartField.builder() + .value("value") + .contentType("text/plain") + .build(), + "binary" to + MultipartField.builder() + .value("abc".toByteArray()) + .contentType("application/octet-stream") + .build(), + ), + ) + + val output = ByteArrayOutputStream() + body.writeTo(output) + + body.content().use { content -> + assertThat(content.readBytes()).isEqualTo(output.toByteArray()) + } + } + + @Test + fun multipartContent_streamsInputStreamParts() { + val body = + multipartFormData( + jsonMapper(), + mapOf( + "data" to + MultipartField.builder() + .value("stream content".byteInputStream().buffered()) + .contentType("application/octet-stream") + .build() + ), + ) + + val content = body.content().use { it.readBytes().toString(Charsets.UTF_8) } + + assertThat(body.repeatable()).isFalse() + assertThat(content).contains("Content-Disposition: form-data; name=\"data\"") + assertThat(content).contains("Content-Type: application/octet-stream") + assertThat(content).contains("stream content") + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/HttpRequestBodyJavaTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/HttpRequestBodyJavaTest.kt new file mode 100644 index 000000000..8b850e68e --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/HttpRequestBodyJavaTest.kt @@ -0,0 +1,88 @@ +package com.openai.core.http + +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import javax.tools.ToolProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +internal class HttpRequestBodyJavaTest { + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun javaImplementationUsesDefaultContent( + compiledAgainstOldInterface: Boolean, + @TempDir directory: Path, + ) { + val sources = mutableListOf() + if (compiledAgainstOldInterface) { + // Load this implementation against today's SDK after compiling against the old API. + val api = directory.resolve("com/openai/core/http/HttpRequestBody.java") + Files.createDirectories(api.parent) + Files.write( + api, + """ + package com.openai.core.http; + public interface HttpRequestBody extends AutoCloseable { + void writeTo(java.io.OutputStream output); + String contentType(); + long contentLength(); + boolean repeatable(); + void close(); + } + """ + .trimIndent() + .toByteArray(), + ) + sources.add(api) + } + val implementation = directory.resolve("LegacyBody.java") + Files.write( + implementation, + """ + public final class LegacyBody implements com.openai.core.http.HttpRequestBody { + public void writeTo(java.io.OutputStream output) { + try { output.write(new byte[] {1, 2, 3}); } + catch (java.io.IOException e) { throw new java.io.UncheckedIOException(e); } + } + public String contentType() { return null; } + public long contentLength() { return 3; } + public boolean repeatable() { return true; } + public void close() {} + } + """ + .trimIndent() + .toByteArray(), + ) + sources.add(implementation) + + val arguments = + listOf( + "--release", + "8", + "-classpath", + System.getProperty("java.class.path"), + "-d", + directory.toString(), + ) + sources.map { it.toString() } + assertThat( + ToolProvider.getSystemJavaCompiler() + .run(null, null, null, *arguments.toTypedArray()) + ) + .isZero() + + URLClassLoader(arrayOf(directory.toUri().toURL()), HttpRequestBody::class.java.classLoader) + .use { loader -> + val body = + loader.loadClass("LegacyBody").getDeclaredConstructor().newInstance() + as HttpRequestBody + body.use { + it.content().use { content -> + assertThat(content.readBytes()).isEqualTo(byteArrayOf(1, 2, 3)) + } + } + } + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/LoggingHttpClientTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/LoggingHttpClientTest.kt index 14f6dbf4d..33a59ec8d 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/LoggingHttpClientTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/LoggingHttpClientTest.kt @@ -22,6 +22,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.ResourceLock import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.junit.jupiter.params.provider.ValueSource import org.slf4j.LoggerFactory @@ -848,6 +849,78 @@ internal class LoggingHttpClientTest { ) } + @ParameterizedTest + @CsvSource("false,false", "false,true", "true,false", "true,true") + fun debugLevel_contentStreamsWithoutFallingBackToWriteTo(async: Boolean, closeEarly: Boolean) { + val bytes = "streaming body".toByteArray(StandardCharsets.UTF_8) + var contentCalls = 0 + var closeCalls = 0 + val requestBody = + object : HttpRequestBody { + override fun writeTo(outputStream: OutputStream) { + error("writeTo must not be used by the content consumer") + } + + override fun content(): InputStream { + contentCalls++ + return object : ByteArrayInputStream(bytes) { + override fun close() { + closeCalls++ + } + } + } + + override fun contentType(): String = "text/plain" + + override fun contentLength(): Long = bytes.size.toLong() + + override fun repeatable(): Boolean = false + + override fun close() {} + } + val delegate = + object : HttpClient { + override fun execute( + request: HttpRequest, + requestOptions: RequestOptions, + ): HttpResponse { + request.body!!.content().use { + assertThat(it.read(ByteArray(0))).isZero() + if (closeEarly) assertThat(it.read()).isEqualTo(bytes[0].toInt()) + else { + assertThat(it.readBytes()).isEqualTo(bytes) + assertThat(it.read(ByteArray(0))).isZero() + } + it.close() + } + return fakeResponse(200, Headers.builder().build(), ByteArray(0)) + } + + override fun executeAsync( + request: HttpRequest, + requestOptions: RequestOptions, + ): CompletableFuture = + CompletableFuture.completedFuture(execute(request, requestOptions)) + + override fun close() {} + } + val request = + HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl("https://api.example.com") + .addPathSegment("stream") + .body(requestBody) + .build() + val client = loggingClient(delegate, LogLevel.DEBUG) + + client.execute(request, async).close() + + assertThat(contentCalls).isEqualTo(1) + assertThat(closeCalls).isEqualTo(1) + val suffix = if (closeEarly) "1-byte body, closed early" else "${bytes.size}-byte body" + assertThat(stderrOutput()).contains("--> END POST ($suffix)") + } + @Test fun builder_toBuilder_roundtrips() { val delegate = fakeHttpClient()