Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions sdk-core/api/sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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 <init> ()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 <init> (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 <init> (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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Part>,
) : RequestBody() {
/** An unmodifiable view of the parts in send order. */
public val parts: List<Part> = 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<Pair<String, String>>,
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<Pair<String, String>> {
require(name.isNotEmpty()) { "Part name must not be empty" }
val headers = ArrayList<Pair<String, String>>(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<MultipartBody> {
private val parts = ArrayList<Part>()
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()
}
}
Loading
Loading