diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..4cb02125 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -1031,6 +1031,65 @@ public final class org/dexpace/sdk/core/http/request/Method : java/lang/Enum { public static fun values ()[Lorg/dexpace/sdk/core/http/request/Method; } +public final class org/dexpace/sdk/core/http/request/MultipartBody : org/dexpace/sdk/core/http/request/RequestBody { + public static final field Companion Lorg/dexpace/sdk/core/http/request/MultipartBody$Companion; + public synthetic fun (Ljava/lang/String;Ljava/util/List;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun builder ()Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public fun contentLength ()J + public static final fun generateBoundary ()Ljava/lang/String; + public final fun getBoundary ()Ljava/lang/String; + public final fun getParts ()Ljava/util/List; + public fun isReplayable ()Z + public fun mediaType ()Lorg/dexpace/sdk/core/http/common/MediaType; + public fun toReplayable (Lorg/dexpace/sdk/core/io/IoProvider;)Lorg/dexpace/sdk/core/http/request/RequestBody; + public fun writeTo (Lorg/dexpace/sdk/core/io/BufferedSink;)V +} + +public final class org/dexpace/sdk/core/http/request/MultipartBody$Builder : org/dexpace/sdk/core/generics/Builder { + public fun ()V + public final fun addFile (Ljava/lang/String;Ljava/nio/file/Path;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public final fun addFile (Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public final fun addFile (Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public static synthetic fun addFile$default (Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public final fun addPart (Ljava/lang/String;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public final fun addPart (Ljava/lang/String;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public final fun addPart (Lorg/dexpace/sdk/core/http/request/MultipartBody$Part;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public static synthetic fun addPart$default (Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder;Ljava/lang/String;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/http/common/MediaType;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public final fun boundary (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/http/request/MultipartBody; +} + +public final class org/dexpace/sdk/core/http/request/MultipartBody$Companion { + public final fun builder ()Lorg/dexpace/sdk/core/http/request/MultipartBody$Builder; + public final fun generateBoundary ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/http/request/MultipartBody$Part { + public static final field Companion Lorg/dexpace/sdk/core/http/request/MultipartBody$Part$Companion; + public synthetic fun (Ljava/util/List;Lorg/dexpace/sdk/core/http/request/RequestBody;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static final fun create (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static final fun file (Ljava/lang/String;Ljava/nio/file/Path;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static final fun file (Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static final fun file (Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static final fun serialized (Ljava/lang/String;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static final fun serialized (Ljava/lang/String;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; +} + +public final class org/dexpace/sdk/core/http/request/MultipartBody$Part$Companion { + public final fun create (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public final fun create (Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static synthetic fun create$default (Lorg/dexpace/sdk/core/http/request/MultipartBody$Part$Companion;Ljava/lang/String;Lorg/dexpace/sdk/core/http/request/RequestBody;Ljava/lang/String;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public final fun file (Ljava/lang/String;Ljava/nio/file/Path;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public final fun file (Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public final fun file (Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static synthetic fun file$default (Lorg/dexpace/sdk/core/http/request/MultipartBody$Part$Companion;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Lorg/dexpace/sdk/core/http/common/MediaType;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public final fun serialized (Ljava/lang/String;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public final fun serialized (Ljava/lang/String;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/http/common/MediaType;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; + public static synthetic fun serialized$default (Lorg/dexpace/sdk/core/http/request/MultipartBody$Part$Companion;Ljava/lang/String;Ljava/lang/Object;Lorg/dexpace/sdk/core/serde/Serde;Lorg/dexpace/sdk/core/http/common/MediaType;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/request/MultipartBody$Part; +} + public final class org/dexpace/sdk/core/http/request/Request { public static final field Companion Lorg/dexpace/sdk/core/http/request/Request$Companion; public synthetic fun (Lorg/dexpace/sdk/core/http/request/Method;Ljava/net/URL;Lorg/dexpace/sdk/core/http/common/Headers;Lorg/dexpace/sdk/core/http/request/RequestBody;Lkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/MultipartBody.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/MultipartBody.kt new file mode 100644 index 00000000..74a64a75 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/MultipartBody.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.request + +import org.dexpace.sdk.core.http.common.MediaType +import org.dexpace.sdk.core.io.BufferedSink +import org.dexpace.sdk.core.io.IoProvider +import org.dexpace.sdk.core.serde.Serde +import java.io.IOException +import java.nio.file.Path +import java.security.SecureRandom +import java.util.Collections + +/** + * A `multipart/form-data` [RequestBody] (RFC 7578) made up of one or more [Part]s separated by a + * generated [boundary]. + * + * Each part contributes a fixed frame — the `--boundary` delimiter, the part's headers, a blank + * line, the part body, and a trailing CRLF — followed by a single closing `--boundary--` delimiter + * for the whole body. The frame layout is produced by **one** shared function ([frameOf]) that both + * [writeTo] and [contentLength] consume, so the declared length can never drift from the bytes + * actually written. + * + * ## Part bodies + * + * Parts wrap an ordinary [RequestBody]: + * + * - **File parts** ([Part.file]) wrap a [FileRequestBody]; the bytes stream straight off disk, and a + * transport that recognises [FileRequestBody] could still dispatch a zero-copy transfer for the + * file region (the multipart framing around it is written normally). + * - **Value parts** ([Part.serialized]) encode an arbitrary object through the [Serde] SPI at + * construction time, so no serializer is required at send time and `sdk-core` keeps zero runtime + * serialization deps. + * - **Raw parts** ([Part.create]) wrap any [RequestBody] you already have. + * + * ## Length and replayability + * + * [contentLength] is exact only when every part body reports a known length; if any part body + * returns `-1`, the whole multipart body returns `-1`. [isReplayable] is true only when every part + * body is replayable, in which case the multipart body can be re-sent across retries without + * buffering. + * + * ## Thread-safety + * + * Immutable after construction. As with all [RequestBody] implementations, a single instance is not + * safe for concurrent [writeTo]; the per-part bodies enforce their own single-use guards where they + * apply. + */ +public class MultipartBody private constructor( + /** The generated (or supplied) multipart boundary token, without surrounding dashes. */ + public val boundary: String, + parts: List, +) : RequestBody() { + /** An unmodifiable view of the parts in send order. */ + public val parts: List = Collections.unmodifiableList(ArrayList(parts)) + + private val contentType: MediaType = + MediaType.of("multipart", "form-data", mapOf("boundary" to boundary)) + + init { + require(this.parts.isNotEmpty()) { "A multipart body must contain at least one part" } + } + + override fun mediaType(): MediaType = contentType + + /** + * Returns the exact total byte count, or `-1` when any part body's length is unknown. + * + * Every byte counted here is produced by [frameOf]/[closingDelimiter] — the same functions + * [writeTo] writes — so the two cannot disagree. + */ + override fun contentLength(): Long { + var total = 0L + for (part in parts) { + val bodyLength = part.body.contentLength() + if (bodyLength < 0) return -1 + total += frameOf(part).size.toLong() + bodyLength + CRLF.size.toLong() + } + return total + closingDelimiter().size.toLong() + } + + override fun isReplayable(): Boolean = parts.all { it.body.isReplayable() } + + override fun toReplayable(provider: IoProvider): RequestBody { + if (isReplayable()) return this + return super.toReplayable(provider) + } + + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + for (part in parts) { + sink.write(frameOf(part)) + part.body.writeTo(sink) + sink.write(CRLF) + } + sink.write(closingDelimiter()) + } + + /** + * Builds the leading frame bytes for [part]: the `--boundary` delimiter, the part headers, and + * the blank line that separates headers from the part body. This is the **single** definition of + * a part's framing — both [writeTo] and [contentLength] go through it, so a change here updates + * both at once and they cannot drift. + */ + private fun frameOf(part: Part): ByteArray { + val sb = StringBuilder() + sb.append(DASHES).append(boundary).append(CRLF_STR) + for ((name, value) in part.headers) { + sb.append(name).append(": ").append(value).append(CRLF_STR) + } + sb.append(CRLF_STR) + return sb.toString().toByteArray(Charsets.UTF_8) + } + + /** The closing `--boundary--` delimiter plus its trailing CRLF that terminates the body. */ + private fun closingDelimiter(): ByteArray = (DASHES + boundary + DASHES + CRLF_STR).toByteArray(Charsets.UTF_8) + + /** + * One field of a [MultipartBody]: a `Content-Disposition` (built from `name` and an optional + * filename), any extra headers, and a [body]. + * + * Construct parts through the [companion object][Part.Companion] factories rather than directly. + */ + public class Part private constructor( + internal val headers: List>, + internal val body: RequestBody, + ) { + public companion object { + /** + * Wraps an existing [body] as a form field named [name], optionally carrying a + * [filename] in its `Content-Disposition`. A `Content-Type` header is emitted when the + * [body] reports a [RequestBody.mediaType]. + */ + @JvmStatic + @JvmOverloads + public fun create( + name: String, + body: RequestBody, + filename: String? = null, + ): Part = Part(buildHeaders(name, filename, body.mediaType()), body) + + /** + * A file field named [name]. The file streams off disk via [FileRequestBody]; the + * supplied [filename] (defaulting to the file's own name) and [mediaType] populate the + * part headers. + */ + @JvmStatic + @JvmOverloads + public fun file( + name: String, + file: Path, + filename: String? = file.fileName?.toString(), + mediaType: MediaType? = null, + ): Part { + val body = FileRequestBody(file, mediaType) + return Part(buildHeaders(name, filename, body.mediaType()), body) + } + + /** + * A value field named [name] whose [value] is encoded through [serde]'s serializer at + * construction time. The resulting bytes are replayable; the part carries the supplied + * [mediaType] (the serialized bytes are opaque to `sdk-core`, so the caller names the + * type). + */ + @JvmStatic + @JvmOverloads + public fun serialized( + name: String, + value: Any, + serde: Serde, + mediaType: MediaType? = null, + ): Part { + val bytes = serde.serializer.serializeToByteArray(value) + val body = RequestBody.create(bytes, mediaType) + return Part(buildHeaders(name, null, mediaType), body) + } + + private fun buildHeaders( + name: String, + filename: String?, + mediaType: MediaType?, + ): List> { + require(name.isNotEmpty()) { "Part name must not be empty" } + val headers = ArrayList>(2) + val disposition = + StringBuilder("form-data; name=").append(quote(name)).apply { + if (filename != null) append("; filename=").append(quote(filename)) + } + headers.add("Content-Disposition" to disposition.toString()) + if (mediaType != null) { + headers.add("Content-Type" to mediaType.toString()) + } + return headers + } + + /** + * Renders [value] as an RFC 7578 quoted-string: wrapped in double quotes with `"` and + * any CR/LF percent-encoded so a header value can never be broken across lines or + * smuggle extra headers. + */ + private fun quote(value: String): String { + val sb = StringBuilder(value.length + 2) + sb.append('"') + for (ch in value) { + when (ch) { + '"' -> sb.append("%22") + '\r' -> sb.append("%0D") + '\n' -> sb.append("%0A") + else -> sb.append(ch) + } + } + sb.append('"') + return sb.toString() + } + } + } + + /** + * Builds [MultipartBody] instances. Append parts with the typed helpers or [addPart], then call + * [build]. A [boundary] is generated when none is set. + */ + public class Builder : org.dexpace.sdk.core.generics.Builder { + private val parts = ArrayList() + private var boundary: String? = null + + /** Overrides the generated boundary token (without surrounding dashes). */ + public fun boundary(boundary: String): Builder = + apply { + require(boundary.isNotEmpty()) { "boundary must not be empty" } + this.boundary = boundary + } + + /** Appends [part] in send order. */ + public fun addPart(part: Part): Builder = apply { parts.add(part) } + + /** Convenience: appends a value field named [name] encoded through [serde]. */ + @JvmOverloads + public fun addPart( + name: String, + value: Any, + serde: Serde, + mediaType: MediaType? = null, + ): Builder = apply { parts.add(Part.serialized(name, value, serde, mediaType)) } + + /** Convenience: appends a file field named [name]. */ + @JvmOverloads + public fun addFile( + name: String, + file: Path, + filename: String? = file.fileName?.toString(), + mediaType: MediaType? = null, + ): Builder = apply { parts.add(Part.file(name, file, filename, mediaType)) } + + override fun build(): MultipartBody = MultipartBody(boundary ?: generateBoundary(), parts) + } + + public companion object { + private const val DASHES: String = "--" + private const val CRLF_STR: String = "\r\n" + private val CRLF: ByteArray = CRLF_STR.toByteArray(Charsets.UTF_8) + + /** Characters used for the random portion of a generated boundary (RFC 7230 token chars). */ + private const val BOUNDARY_ALPHABET: String = + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + private const val BOUNDARY_RANDOM_LENGTH: Int = 32 + private val RANDOM: SecureRandom = SecureRandom() + + /** + * Generates a fresh boundary unlikely to collide with any part body's bytes: a fixed + * `dexpace-` prefix followed by [BOUNDARY_RANDOM_LENGTH] random token characters. + */ + @JvmStatic + public fun generateBoundary(): String { + val sb = StringBuilder("dexpace-") + repeat(BOUNDARY_RANDOM_LENGTH) { + sb.append(BOUNDARY_ALPHABET[RANDOM.nextInt(BOUNDARY_ALPHABET.length)]) + } + return sb.toString() + } + + /** Starts a new [Builder]. */ + @JvmStatic + public fun builder(): Builder = Builder() + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/MultipartBodyTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/MultipartBodyTest.kt new file mode 100644 index 00000000..ae428770 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/MultipartBodyTest.kt @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2026 dexpace and Omar Aljarrah + * + * Licensed under the MIT License. See LICENSE in the project root. + * SPDX-License-Identifier: MIT + */ + +package org.dexpace.sdk.core.http.request + +import org.dexpace.sdk.core.http.common.CommonMediaTypes +import org.dexpace.sdk.core.http.common.MediaType +import org.dexpace.sdk.core.io.BufferedSink +import org.dexpace.sdk.core.io.Io +import org.dexpace.sdk.core.serde.Deserializer +import org.dexpace.sdk.core.serde.Serde +import org.dexpace.sdk.core.serde.Serializer +import org.dexpace.sdk.io.OkioIoProvider +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class MultipartBodyTest { + @BeforeTest + fun installProvider() { + Io.installProvider(OkioIoProvider) + } + + private val tempFiles = mutableListOf() + + @AfterTest + fun cleanup() { + for (path in tempFiles.reversed()) { + try { + Files.deleteIfExists(path) + } catch (_: Throwable) { + // best effort + } + } + tempFiles.clear() + } + + private fun tempFile(content: ByteArray): Path { + val p = Files.createTempFile("multipart-test", ".bin") + Files.write(p, content) + tempFiles.add(p) + return p + } + + /** A trivial [Serde] that encodes a String as raw UTF-8 — no Jackson in core. */ + private object StringSerde : Serde { + override val serializer: Serializer = + object : Serializer { + override fun serialize(input: Any): String = input.toString() + + override fun serializeToByteArray(input: Any): ByteArray = input.toString().toByteArray(Charsets.UTF_8) + + override fun serialize( + input: Any, + outputStream: OutputStream, + ) { + outputStream.write(serializeToByteArray(input)) + } + + override fun serialize( + input: Any, + buffer: ByteArray, + offset: Int, + ): Int { + val bytes = serializeToByteArray(input) + System.arraycopy(bytes, 0, buffer, offset, bytes.size) + return bytes.size + } + } + + override val deserializer: Deserializer = + object : Deserializer { + override fun deserialize( + input: String, + type: Class, + ): T = throw UnsupportedOperationException() + + override fun deserialize( + input: ByteArray, + type: Class, + ): T = throw UnsupportedOperationException() + + override fun deserialize( + inputStream: java.io.InputStream, + type: Class, + ): T = throw UnsupportedOperationException() + } + } + + private fun drain(body: RequestBody): ByteArray { + val buf = Io.provider.buffer() + body.writeTo(buf) + return buf.snapshot() + } + + // --------------------------------------------------------------------- + // Boundary + content type + // --------------------------------------------------------------------- + + @Test + fun `generated boundary is unique per call and token-safe`() { + val a = MultipartBody.generateBoundary() + val b = MultipartBody.generateBoundary() + assertNotEquals(a, b) + assertTrue(a.startsWith("dexpace-")) + assertTrue(a.all { it.isLetterOrDigit() || it == '-' }) + } + + @Test + fun `media type is multipart form-data with boundary parameter`() { + val body = + MultipartBody.builder() + .boundary("abc123") + .addPart("field", "value", StringSerde) + .build() + val mediaType = body.mediaType() + assertEquals("multipart", mediaType.type) + assertEquals("form-data", mediaType.subtype) + assertEquals("abc123", mediaType.parameters["boundary"]) + assertEquals("multipart/form-data;boundary=abc123", mediaType.toString()) + } + + @Test + fun `empty body is rejected`() { + assertFailsWith { MultipartBody.builder().build() } + } + + @Test + fun `empty boundary is rejected`() { + assertFailsWith { MultipartBody.builder().boundary("") } + } + + // --------------------------------------------------------------------- + // contentLength matches writeTo — the core invariant + // --------------------------------------------------------------------- + + @Test + fun `contentLength matches bytes written for value parts`() { + val body = + MultipartBody.builder() + .boundary("len-check") + .addPart("a", "hello", StringSerde, CommonMediaTypes.TEXT_PLAIN) + .addPart("b", "world", StringSerde) + .build() + val written = drain(body) + assertEquals(written.size.toLong(), body.contentLength()) + } + + @Test + fun `contentLength matches bytes written for mixed file and value parts`() { + val file = tempFile("file-contents-1234567890".toByteArray()) + val body = + MultipartBody.builder() + .boundary("mixed-check") + .addPart("meta", "some-json", StringSerde, CommonMediaTypes.APPLICATION_JSON) + .addFile("upload", file, "report.bin", CommonMediaTypes.APPLICATION_OCTET_STREAM) + .build() + val written = drain(body) + assertEquals(written.size.toLong(), body.contentLength()) + } + + @Test + fun `contentLength is unknown when a part body length is unknown`() { + val unknownBody = + object : RequestBody() { + override fun mediaType(): MediaType? = null + + override fun contentLength(): Long = -1 + + override fun writeTo(sink: BufferedSink) { + sink.writeUtf8("x") + } + } + val body = + MultipartBody.builder() + .boundary("unknown") + .addPart(MultipartBody.Part.create("a", unknownBody)) + .build() + assertEquals(-1L, body.contentLength()) + } + + // --------------------------------------------------------------------- + // Wire-format round trip + // --------------------------------------------------------------------- + + @Test + fun `multi-part wire format is correct`() { + val body = + MultipartBody.builder() + .boundary("BOUNDARY") + .addPart("name", "Omar", StringSerde, CommonMediaTypes.TEXT_PLAIN) + .build() + val wire = String(drain(body), Charsets.UTF_8) + val expected = + "--BOUNDARY\r\n" + + "Content-Disposition: form-data; name=\"name\"\r\n" + + "Content-Type: text/plain\r\n" + + "\r\n" + + "Omar\r\n" + + "--BOUNDARY--\r\n" + assertEquals(expected, wire) + } + + @Test + fun `file part emits filename in content disposition`() { + val file = tempFile("PNGDATA".toByteArray()) + val body = + MultipartBody.builder() + .boundary("B") + .addFile("image", file, "photo.png", CommonMediaTypes.IMAGE_PNG) + .build() + val wire = String(drain(body), Charsets.UTF_8) + assertContains(wire, "Content-Disposition: form-data; name=\"image\"; filename=\"photo.png\"") + assertContains(wire, "Content-Type: image/png") + assertContains(wire, "PNGDATA") + } + + @Test + fun `closing delimiter terminates the body exactly once`() { + val body = + MultipartBody.builder() + .boundary("END") + .addPart("a", "1", StringSerde) + .addPart("b", "2", StringSerde) + .build() + val wire = String(drain(body), Charsets.UTF_8) + assertTrue(wire.endsWith("--END--\r\n")) + // The closing delimiter appears once; the opening delimiters appear per part. + assertEquals(2, "--END\r\n".toRegex().findAll(wire).count()) + } + + @Test + fun `name and filename with special characters are escaped`() { + val file = tempFile("x".toByteArray()) + val body = + MultipartBody.builder() + .boundary("B") + .addFile("na\"me", file, "a\r\nb\"c.txt") + .build() + val wire = String(drain(body), Charsets.UTF_8) + assertContains(wire, "name=\"na%22me\"") + assertContains(wire, "filename=\"a%0D%0Ab%22c.txt\"") + // No raw CR/LF smuggled into the header beyond the framing CRLFs. + assertFalse(wire.contains("a\r\nb")) + } + + // --------------------------------------------------------------------- + // Replayability + // --------------------------------------------------------------------- + + @Test + fun `body of all replayable parts is replayable and produces identical bytes`() { + val file = tempFile("filedata".toByteArray()) + val body = + MultipartBody.builder() + .boundary("REPLAY") + .addPart("a", "value", StringSerde) + .addFile("f", file) + .build() + assertTrue(body.isReplayable()) + assertSame(body, body.toReplayable()) + val first = drain(body) + val second = drain(body) + assertEquals(String(first, Charsets.UTF_8), String(second, Charsets.UTF_8)) + } + + @Test + fun `body with a non-replayable part is not replayable but can be buffered`() { + val oneShot = + object : RequestBody() { + override fun mediaType(): MediaType = CommonMediaTypes.TEXT_PLAIN + + override fun contentLength(): Long = 8 + + override fun isReplayable(): Boolean = false + + override fun writeTo(sink: BufferedSink) { + sink.writeUtf8("streamed") + } + } + val body = + MultipartBody.builder() + .boundary("ONESHOT") + .addPart(MultipartBody.Part.create("a", oneShot)) + .build() + assertFalse(body.isReplayable()) + val replayable = body.toReplayable() + assertNotEquals(body, replayable) + assertTrue(replayable.isReplayable()) + // The buffered copy is byte-identical to a fresh render would have been. + val rendered = String(drain(replayable), Charsets.UTF_8) + assertContains(rendered, "streamed") + assertContains(rendered, "--ONESHOT--\r\n") + } + + @Test + fun `serialized part uses the serde and carries the declared media type`() { + val body = + MultipartBody.builder() + .boundary("S") + .addPart("payload", "raw-value", StringSerde, CommonMediaTypes.APPLICATION_JSON) + .build() + val wire = String(drain(body), Charsets.UTF_8) + assertContains(wire, "Content-Type: application/json") + assertContains(wire, "raw-value") + } + + @Test + fun `parts list is unmodifiable`() { + val body = MultipartBody.builder().boundary("B").addPart("a", "1", StringSerde).build() + assertEquals(1, body.parts.size) + assertFailsWith { + @Suppress("UNCHECKED_CAST") + (body.parts as MutableList).clear() + } + } + + @Test + fun `empty part name is rejected`() { + assertFailsWith { + MultipartBody.Part.serialized("", "v", StringSerde) + } + } +}