diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 4208b5cb..1b77f506 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.6.0"
+ ".": "0.7.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index 85a351b6..fb126643 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 44
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sent%2Fsent-dm-433bfd8c688a6b6d2d4f964bb59121d692798f4e2bb6cb47f6110c4f0e1f638d.yml
-openapi_spec_hash: 5378295d401c8c1152c1946cc7dbd69f
-config_hash: 43a0daa5b05d44a1620e3da0ea6f4fdc
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sent%2Fsent-dm-8f8e43f2568f02505d53d422fb814604dd9534de6f990f9ae460e5513613da68.yml
+openapi_spec_hash: b7a6855c6f0a9892f450f0bc67031d4e
+config_hash: d475a61f5b59375bf562f85f19b80409
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6efd531..2c742177 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+## 0.7.0 (2026-02-26)
+
+Full Changelog: [v0.6.0...v0.7.0](https://github.com/sentdm/sent-dm-java/compare/v0.6.0...v0.7.0)
+
+### Features
+
+* **client:** add connection pooling option ([7d04245](https://github.com/sentdm/sent-dm-java/commit/7d04245815d1ad0a7690adf6f53ae98e574bc56c))
+
+
+### Chores
+
+* configure new SDK language ([668fa3e](https://github.com/sentdm/sent-dm-java/commit/668fa3ea4e37e9a6d4b27768069f6e8745c4702c))
+* drop apache dependency ([9edbed1](https://github.com/sentdm/sent-dm-java/commit/9edbed1de0c2d3bcfc04a50de05bab10174d70dd))
+* **internal:** expand imports ([47e0e22](https://github.com/sentdm/sent-dm-java/commit/47e0e221dc542bd54edfffdccdec5f55db08f352))
+* **internal:** make `OkHttp` constructor internal ([6389eb0](https://github.com/sentdm/sent-dm-java/commit/6389eb0a1cebb8e7291af86de87578f426601c14))
+* **internal:** remove mock server code ([d81a366](https://github.com/sentdm/sent-dm-java/commit/d81a3667dd4f8d761f6f4e5ff90dc2fcb3882a1c))
+* make `Properties` more resilient to `null` ([b83c0cf](https://github.com/sentdm/sent-dm-java/commit/b83c0cf94dedc9bc6b4b9057a0c26332e3ef2541))
+* update mock server docs ([e1413d2](https://github.com/sentdm/sent-dm-java/commit/e1413d2932000755edf64a0422f5cd99a9f82544))
+
## 0.6.0 (2026-02-18)
Full Changelog: [v0.5.1...v0.6.0](https://github.com/sentdm/sent-dm-java/compare/v0.5.1...v0.6.0)
diff --git a/README.md b/README.md
index 1f286c1a..3183430f 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
-[](https://central.sonatype.com/artifact/dm.sent/sent-dm-java/0.6.0)
-[](https://javadoc.io/doc/dm.sent/sent-dm-java/0.6.0)
+[](https://central.sonatype.com/artifact/dm.sent/sent-dm-java/0.7.0)
+[](https://javadoc.io/doc/dm.sent/sent-dm-java/0.7.0)
@@ -11,9 +11,18 @@ The Sent Dm Java SDK provides convenient access to the [Sent Dm REST API](https:
It is generated with [Stainless](https://www.stainless.com/).
+## MCP Server
+
+Use the Sent Dm MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application.
+
+[](https://cursor.com/en-US/install-mcp?name=%40sentdm%2Fsentdm-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBzZW50ZG0vc2VudGRtLW1jcCJdLCJlbnYiOnsiU0VOVF9ETV9BUElfS0VZIjoiTXkgQVBJIEtleSJ9fQ)
+[](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40sentdm%2Fsentdm-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40sentdm%2Fsentdm-mcp%22%5D%2C%22env%22%3A%7B%22SENT_DM_API_KEY%22%3A%22My%20API%20Key%22%7D%7D)
+
+> Note: You may need to set environment variables in your MCP client.
+
-The REST API documentation can be found on [docs.sent.dm](https://docs.sent.dm). Javadocs are available on [javadoc.io](https://javadoc.io/doc/dm.sent/sent-dm-java/0.6.0).
+The REST API documentation can be found on [docs.sent.dm](https://docs.sent.dm). Javadocs are available on [javadoc.io](https://javadoc.io/doc/dm.sent/sent-dm-java/0.7.0).
@@ -24,7 +33,7 @@ The REST API documentation can be found on [docs.sent.dm](https://docs.sent.dm).
### Gradle
```kotlin
-implementation("dm.sent:sent-dm-java:0.6.0")
+implementation("dm.sent:sent-dm-java:0.7.0")
```
### Maven
@@ -33,7 +42,7 @@ implementation("dm.sent:sent-dm-java:0.6.0")
dm.sent
sent-dm-java
- 0.6.0
+ 0.7.0
```
@@ -391,6 +400,25 @@ SentDmClient client = SentDmOkHttpClient.builder()
.build();
```
+### Connection pooling
+
+To customize the underlying OkHttp connection pool, configure the client using the `maxIdleConnections` and `keepAliveDuration` methods:
+
+```java
+import dm.sent.client.SentDmClient;
+import dm.sent.client.okhttp.SentDmOkHttpClient;
+import java.time.Duration;
+
+SentDmClient client = SentDmOkHttpClient.builder()
+ .fromEnv()
+ // If `maxIdleConnections` is set, then `keepAliveDuration` must be set, and vice versa.
+ .maxIdleConnections(10)
+ .keepAliveDuration(Duration.ofMinutes(2))
+ .build();
+```
+
+If both options are unset, OkHttp's default connection pool settings are used.
+
### HTTPS
> [!NOTE]
diff --git a/build.gradle.kts b/build.gradle.kts
index 061efe6b..21b13773 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ repositories {
allprojects {
group = "dm.sent"
- version = "0.6.0" // x-release-please-version
+ version = "0.7.0" // x-release-please-version
}
subprojects {
diff --git a/scripts/mock b/scripts/mock
deleted file mode 100755
index 0b28f6ea..00000000
--- a/scripts/mock
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-
-cd "$(dirname "$0")/.."
-
-if [[ -n "$1" && "$1" != '--'* ]]; then
- URL="$1"
- shift
-else
- URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)"
-fi
-
-# Check if the URL is empty
-if [ -z "$URL" ]; then
- echo "Error: No OpenAPI spec path/url provided or found in .stats.yml"
- exit 1
-fi
-
-echo "==> Starting mock server with URL ${URL}"
-
-# Run prism mock on the given spec
-if [ "$1" == "--daemon" ]; then
- npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log &
-
- # Wait for server to come online
- echo -n "Waiting for server"
- while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do
- echo -n "."
- sleep 0.1
- done
-
- if grep -q "✖ fatal" ".prism.log"; then
- cat .prism.log
- exit 1
- fi
-
- echo
-else
- npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL"
-fi
diff --git a/scripts/test b/scripts/test
index 047bc1db..904aea60 100755
--- a/scripts/test
+++ b/scripts/test
@@ -4,53 +4,7 @@ set -e
cd "$(dirname "$0")/.."
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[0;33m'
-NC='\033[0m' # No Color
-function prism_is_running() {
- curl --silent "http://localhost:4010" >/dev/null 2>&1
-}
-
-kill_server_on_port() {
- pids=$(lsof -t -i tcp:"$1" || echo "")
- if [ "$pids" != "" ]; then
- kill "$pids"
- echo "Stopped $pids."
- fi
-}
-
-function is_overriding_api_base_url() {
- [ -n "$TEST_API_BASE_URL" ]
-}
-
-if ! is_overriding_api_base_url && ! prism_is_running ; then
- # When we exit this script, make sure to kill the background mock server process
- trap 'kill_server_on_port 4010' EXIT
-
- # Start the dev server
- ./scripts/mock --daemon
-fi
-
-if is_overriding_api_base_url ; then
- echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}"
- echo
-elif ! prism_is_running ; then
- echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server"
- echo -e "running against your OpenAPI spec."
- echo
- echo -e "To run the server, pass in the path or url of your OpenAPI"
- echo -e "spec to the prism command:"
- echo
- echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}"
- echo
-
- exit 1
-else
- echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}"
- echo
-fi
echo "==> Running tests"
./gradlew test "$@"
diff --git a/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/OkHttpClient.kt b/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/OkHttpClient.kt
index d82584f0..273334a6 100644
--- a/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/OkHttpClient.kt
+++ b/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/OkHttpClient.kt
@@ -16,11 +16,13 @@ import java.time.Duration
import java.util.concurrent.CancellationException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
+import java.util.concurrent.TimeUnit
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
import okhttp3.Call
import okhttp3.Callback
+import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType
@@ -33,7 +35,7 @@ import okhttp3.logging.HttpLoggingInterceptor
import okio.BufferedSink
class OkHttpClient
-private constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClient) : HttpClient {
+internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClient) : HttpClient {
override fun execute(request: HttpRequest, requestOptions: RequestOptions): HttpResponse {
val call = newCall(request, requestOptions)
@@ -200,6 +202,8 @@ private constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClien
private var timeout: Timeout = Timeout.default()
private var proxy: Proxy? = null
+ private var maxIdleConnections: Int? = null
+ private var keepAliveDuration: Duration? = null
private var dispatcherExecutorService: ExecutorService? = null
private var sslSocketFactory: SSLSocketFactory? = null
private var trustManager: X509TrustManager? = null
@@ -211,6 +215,28 @@ private constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClien
fun proxy(proxy: Proxy?) = apply { this.proxy = proxy }
+ /**
+ * Sets the maximum number of idle connections kept by the underlying [ConnectionPool].
+ *
+ * If this is set, then [keepAliveDuration] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int?) = apply {
+ this.maxIdleConnections = maxIdleConnections
+ }
+
+ /**
+ * Sets the keep-alive duration for idle connections in the underlying [ConnectionPool].
+ *
+ * If this is set, then [maxIdleConnections] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun keepAliveDuration(keepAliveDuration: Duration?) = apply {
+ this.keepAliveDuration = keepAliveDuration
+ }
+
fun dispatcherExecutorService(dispatcherExecutorService: ExecutorService?) = apply {
this.dispatcherExecutorService = dispatcherExecutorService
}
@@ -240,6 +266,22 @@ private constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClien
.apply {
dispatcherExecutorService?.let { dispatcher(Dispatcher(it)) }
+ val maxIdleConnections = maxIdleConnections
+ val keepAliveDuration = keepAliveDuration
+ if (maxIdleConnections != null && keepAliveDuration != null) {
+ connectionPool(
+ ConnectionPool(
+ maxIdleConnections,
+ keepAliveDuration.toNanos(),
+ TimeUnit.NANOSECONDS,
+ )
+ )
+ } else {
+ check((maxIdleConnections != null) == (keepAliveDuration != null)) {
+ "Both or none of `maxIdleConnections` and `keepAliveDuration` must be set, but only one was set"
+ }
+ }
+
val sslSocketFactory = sslSocketFactory
val trustManager = trustManager
if (sslSocketFactory != null && trustManager != null) {
diff --git a/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentDmOkHttpClient.kt b/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentDmOkHttpClient.kt
index c863f2e0..f7acb5fb 100644
--- a/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentDmOkHttpClient.kt
+++ b/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentDmOkHttpClient.kt
@@ -47,6 +47,8 @@ class SentDmOkHttpClient private constructor() {
private var clientOptions: ClientOptions.Builder = ClientOptions.builder()
private var dispatcherExecutorService: ExecutorService? = null
private var proxy: Proxy? = null
+ private var maxIdleConnections: Int? = null
+ private var keepAliveDuration: Duration? = null
private var sslSocketFactory: SSLSocketFactory? = null
private var trustManager: X509TrustManager? = null
private var hostnameVerifier: HostnameVerifier? = null
@@ -75,6 +77,46 @@ class SentDmOkHttpClient private constructor() {
/** Alias for calling [Builder.proxy] with `proxy.orElse(null)`. */
fun proxy(proxy: Optional) = proxy(proxy.getOrNull())
+ /**
+ * The maximum number of idle connections kept by the underlying OkHttp connection pool.
+ *
+ * If this is set, then [keepAliveDuration] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int?) = apply {
+ this.maxIdleConnections = maxIdleConnections
+ }
+
+ /**
+ * Alias for [Builder.maxIdleConnections].
+ *
+ * This unboxed primitive overload exists for backwards compatibility.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int) =
+ maxIdleConnections(maxIdleConnections as Int?)
+
+ /**
+ * Alias for calling [Builder.maxIdleConnections] with `maxIdleConnections.orElse(null)`.
+ */
+ fun maxIdleConnections(maxIdleConnections: Optional) =
+ maxIdleConnections(maxIdleConnections.getOrNull())
+
+ /**
+ * The keep-alive duration for idle connections in the underlying OkHttp connection pool.
+ *
+ * If this is set, then [maxIdleConnections] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun keepAliveDuration(keepAliveDuration: Duration?) = apply {
+ this.keepAliveDuration = keepAliveDuration
+ }
+
+ /** Alias for calling [Builder.keepAliveDuration] with `keepAliveDuration.orElse(null)`. */
+ fun keepAliveDuration(keepAliveDuration: Optional) =
+ keepAliveDuration(keepAliveDuration.getOrNull())
+
/**
* The socket factory used to secure HTTPS connections.
*
@@ -321,6 +363,8 @@ class SentDmOkHttpClient private constructor() {
OkHttpClient.builder()
.timeout(clientOptions.timeout())
.proxy(proxy)
+ .maxIdleConnections(maxIdleConnections)
+ .keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
diff --git a/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentDmOkHttpClientAsync.kt b/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentDmOkHttpClientAsync.kt
index b6b13078..f19121f3 100644
--- a/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentDmOkHttpClientAsync.kt
+++ b/sent-dm-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentDmOkHttpClientAsync.kt
@@ -47,6 +47,8 @@ class SentDmOkHttpClientAsync private constructor() {
private var clientOptions: ClientOptions.Builder = ClientOptions.builder()
private var dispatcherExecutorService: ExecutorService? = null
private var proxy: Proxy? = null
+ private var maxIdleConnections: Int? = null
+ private var keepAliveDuration: Duration? = null
private var sslSocketFactory: SSLSocketFactory? = null
private var trustManager: X509TrustManager? = null
private var hostnameVerifier: HostnameVerifier? = null
@@ -75,6 +77,46 @@ class SentDmOkHttpClientAsync private constructor() {
/** Alias for calling [Builder.proxy] with `proxy.orElse(null)`. */
fun proxy(proxy: Optional) = proxy(proxy.getOrNull())
+ /**
+ * The maximum number of idle connections kept by the underlying OkHttp connection pool.
+ *
+ * If this is set, then [keepAliveDuration] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int?) = apply {
+ this.maxIdleConnections = maxIdleConnections
+ }
+
+ /**
+ * Alias for [Builder.maxIdleConnections].
+ *
+ * This unboxed primitive overload exists for backwards compatibility.
+ */
+ fun maxIdleConnections(maxIdleConnections: Int) =
+ maxIdleConnections(maxIdleConnections as Int?)
+
+ /**
+ * Alias for calling [Builder.maxIdleConnections] with `maxIdleConnections.orElse(null)`.
+ */
+ fun maxIdleConnections(maxIdleConnections: Optional) =
+ maxIdleConnections(maxIdleConnections.getOrNull())
+
+ /**
+ * The keep-alive duration for idle connections in the underlying OkHttp connection pool.
+ *
+ * If this is set, then [maxIdleConnections] must also be set.
+ *
+ * If unset, then OkHttp's default is used.
+ */
+ fun keepAliveDuration(keepAliveDuration: Duration?) = apply {
+ this.keepAliveDuration = keepAliveDuration
+ }
+
+ /** Alias for calling [Builder.keepAliveDuration] with `keepAliveDuration.orElse(null)`. */
+ fun keepAliveDuration(keepAliveDuration: Optional) =
+ keepAliveDuration(keepAliveDuration.getOrNull())
+
/**
* The socket factory used to secure HTTPS connections.
*
@@ -321,6 +363,8 @@ class SentDmOkHttpClientAsync private constructor() {
OkHttpClient.builder()
.timeout(clientOptions.timeout())
.proxy(proxy)
+ .maxIdleConnections(maxIdleConnections)
+ .keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
diff --git a/sent-dm-java-core/build.gradle.kts b/sent-dm-java-core/build.gradle.kts
index f76bf3fb..4f6bd70d 100644
--- a/sent-dm-java-core/build.gradle.kts
+++ b/sent-dm-java-core/build.gradle.kts
@@ -27,8 +27,6 @@ dependencies {
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.18.2")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2")
- implementation("org.apache.httpcomponents.core5:httpcore5:5.2.4")
- implementation("org.apache.httpcomponents.client5:httpclient5:5.3.1")
testImplementation(kotlin("test"))
testImplementation(project(":sent-dm-java-client-okhttp"))
diff --git a/sent-dm-java-core/src/main/kotlin/dm/sent/core/Properties.kt b/sent-dm-java-core/src/main/kotlin/dm/sent/core/Properties.kt
index b6bd00b8..1bb1896c 100644
--- a/sent-dm-java-core/src/main/kotlin/dm/sent/core/Properties.kt
+++ b/sent-dm-java-core/src/main/kotlin/dm/sent/core/Properties.kt
@@ -34,9 +34,9 @@ fun getOsName(): String {
}
}
-fun getOsVersion(): String = System.getProperty("os.version", "unknown")
+fun getOsVersion(): String = System.getProperty("os.version", "unknown") ?: "unknown"
fun getPackageVersion(): String =
- SentDmClient::class.java.`package`.implementationVersion ?: "unknown"
+ SentDmClient::class.java.`package`?.implementationVersion ?: "unknown"
-fun getJavaVersion(): String = System.getProperty("java.version", "unknown")
+fun getJavaVersion(): String = System.getProperty("java.version", "unknown") ?: "unknown"
diff --git a/sent-dm-java-core/src/main/kotlin/dm/sent/core/http/HttpRequestBodies.kt b/sent-dm-java-core/src/main/kotlin/dm/sent/core/http/HttpRequestBodies.kt
index 1d00c8c4..217c1398 100644
--- a/sent-dm-java-core/src/main/kotlin/dm/sent/core/http/HttpRequestBodies.kt
+++ b/sent-dm-java-core/src/main/kotlin/dm/sent/core/http/HttpRequestBodies.kt
@@ -8,13 +8,13 @@ import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.databind.node.JsonNodeType
import dm.sent.core.MultipartField
+import dm.sent.core.toImmutable
import dm.sent.errors.SentDmInvalidDataException
+import java.io.ByteArrayInputStream
import java.io.InputStream
import java.io.OutputStream
+import java.util.UUID
import kotlin.jvm.optionals.getOrNull
-import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder
-import org.apache.hc.core5.http.ContentType
-import org.apache.hc.core5.http.HttpEntity
@JvmSynthetic
internal inline fun json(jsonMapper: JsonMapper, value: T): HttpRequestBody =
@@ -37,92 +37,231 @@ internal fun multipartFormData(
jsonMapper: JsonMapper,
fields: Map>,
): HttpRequestBody =
- object : HttpRequestBody {
- private val entity: HttpEntity by lazy {
- MultipartEntityBuilder.create()
- .apply {
- fields.forEach { (name, field) ->
- val knownValue = field.value.asKnown().getOrNull()
- val parts =
- if (knownValue is InputStream) {
- // Read directly from the `InputStream` instead of reading it all
- // into memory due to the `jsonMapper` serialization below.
- sequenceOf(name to knownValue)
- } else {
- val node = jsonMapper.valueToTree(field.value)
- serializePart(name, node)
+ MultipartBody.Builder()
+ .apply {
+ fields.forEach { (name, field) ->
+ val knownValue = field.value.asKnown().getOrNull()
+ val parts =
+ if (knownValue is InputStream) {
+ // Read directly from the `InputStream` instead of reading it all
+ // into memory due to the `jsonMapper` serialization below.
+ sequenceOf(name to knownValue)
+ } else {
+ val node = jsonMapper.valueToTree(field.value)
+ serializePart(name, node)
+ }
+
+ parts.forEach { (name, bytes) ->
+ val partBody =
+ if (bytes is ByteArrayInputStream) {
+ val byteArray = bytes.readBytes()
+
+ object : HttpRequestBody {
+
+ override fun writeTo(outputStream: OutputStream) {
+ outputStream.write(byteArray)
+ }
+
+ override fun contentType(): String = field.contentType
+
+ override fun contentLength(): Long = byteArray.size.toLong()
+
+ override fun repeatable(): Boolean = true
+
+ override fun close() {}
}
+ } else {
+ object : HttpRequestBody {
+
+ override fun writeTo(outputStream: OutputStream) {
+ bytes.copyTo(outputStream)
+ }
+
+ override fun contentType(): String = field.contentType
+
+ override fun contentLength(): Long = -1L
- parts.forEach { (name, bytes) ->
- addBinaryBody(
- name,
- bytes,
- ContentType.parseLenient(field.contentType),
- field.filename().getOrNull(),
- )
+ override fun repeatable(): Boolean = false
+
+ override fun close() = bytes.close()
+ }
}
- }
+
+ addPart(
+ MultipartBody.Part.create(
+ name,
+ field.filename().getOrNull(),
+ field.contentType,
+ partBody,
+ )
+ )
}
- .build()
+ }
}
+ .build()
- private fun serializePart(
- name: String,
- node: JsonNode,
- ): Sequence> =
- when (node.nodeType) {
- JsonNodeType.MISSING,
- JsonNodeType.NULL -> emptySequence()
- JsonNodeType.BINARY -> sequenceOf(name to node.binaryValue().inputStream())
- JsonNodeType.STRING -> sequenceOf(name to node.textValue().inputStream())
- JsonNodeType.BOOLEAN ->
- sequenceOf(name to node.booleanValue().toString().inputStream())
- JsonNodeType.NUMBER ->
- sequenceOf(name to node.numberValue().toString().inputStream())
- JsonNodeType.ARRAY ->
- sequenceOf(
- name to
- node
- .elements()
- .asSequence()
- .mapNotNull { element ->
- when (element.nodeType) {
- JsonNodeType.MISSING,
- JsonNodeType.NULL -> null
- JsonNodeType.STRING -> node.textValue()
- JsonNodeType.BOOLEAN -> node.booleanValue().toString()
- JsonNodeType.NUMBER -> node.numberValue().toString()
- null,
- JsonNodeType.BINARY,
- JsonNodeType.ARRAY,
- JsonNodeType.OBJECT,
- JsonNodeType.POJO ->
- throw SentDmInvalidDataException(
- "Unexpected JsonNode type in array: ${node.nodeType}"
- )
- }
- }
- .joinToString(",")
- .inputStream()
- )
- JsonNodeType.OBJECT ->
- node.fields().asSequence().flatMap { (key, value) ->
- serializePart("$name[$key]", value)
- }
- JsonNodeType.POJO,
- null ->
- throw SentDmInvalidDataException("Unexpected JsonNode type: ${node.nodeType}")
+private fun serializePart(name: String, node: JsonNode): Sequence> =
+ when (node.nodeType) {
+ JsonNodeType.MISSING,
+ JsonNodeType.NULL -> emptySequence()
+ JsonNodeType.BINARY -> sequenceOf(name to node.binaryValue().inputStream())
+ JsonNodeType.STRING -> sequenceOf(name to node.textValue().byteInputStream())
+ JsonNodeType.BOOLEAN -> sequenceOf(name to node.booleanValue().toString().byteInputStream())
+ JsonNodeType.NUMBER -> sequenceOf(name to node.numberValue().toString().byteInputStream())
+ JsonNodeType.ARRAY ->
+ sequenceOf(
+ name to
+ node
+ .elements()
+ .asSequence()
+ .mapNotNull { element ->
+ when (element.nodeType) {
+ JsonNodeType.MISSING,
+ JsonNodeType.NULL -> null
+ JsonNodeType.STRING -> element.textValue()
+ JsonNodeType.BOOLEAN -> element.booleanValue().toString()
+ JsonNodeType.NUMBER -> element.numberValue().toString()
+ null,
+ JsonNodeType.BINARY,
+ JsonNodeType.ARRAY,
+ JsonNodeType.OBJECT,
+ JsonNodeType.POJO ->
+ throw SentDmInvalidDataException(
+ "Unexpected JsonNode type in array: ${element.nodeType}"
+ )
+ }
+ }
+ .joinToString(",")
+ .byteInputStream()
+ )
+ JsonNodeType.OBJECT ->
+ node.fields().asSequence().flatMap { (key, value) ->
+ serializePart("$name[$key]", value)
+ }
+ JsonNodeType.POJO,
+ null -> throw SentDmInvalidDataException("Unexpected JsonNode type: ${node.nodeType}")
+ }
+
+private class MultipartBody
+private constructor(private val boundary: String, private val parts: List) : HttpRequestBody {
+ private val boundaryBytes: ByteArray = boundary.toByteArray()
+ private val contentType = "multipart/form-data; boundary=$boundary"
+
+ // This must remain in sync with `contentLength`.
+ override fun writeTo(outputStream: OutputStream) {
+ parts.forEach { part ->
+ outputStream.write(DASHDASH)
+ outputStream.write(boundaryBytes)
+ outputStream.write(CRLF)
+
+ outputStream.write(CONTENT_DISPOSITION)
+ outputStream.write(part.contentDisposition.toByteArray())
+ outputStream.write(CRLF)
+
+ outputStream.write(CONTENT_TYPE)
+ outputStream.write(part.contentType.toByteArray())
+ outputStream.write(CRLF)
+
+ outputStream.write(CRLF)
+ part.body.writeTo(outputStream)
+ outputStream.write(CRLF)
+ }
+
+ outputStream.write(DASHDASH)
+ outputStream.write(boundaryBytes)
+ outputStream.write(DASHDASH)
+ outputStream.write(CRLF)
+ }
+
+ override fun contentType(): String = contentType
+
+ // This must remain in sync with `writeTo`.
+ override fun contentLength(): Long {
+ var byteCount = 0L
+
+ parts.forEach { part ->
+ val contentLength = part.body.contentLength()
+ if (contentLength == -1L) {
+ return -1L
}
- private fun String.inputStream(): InputStream = toByteArray().inputStream()
+ byteCount +=
+ DASHDASH.size +
+ boundaryBytes.size +
+ CRLF.size +
+ CONTENT_DISPOSITION.size +
+ part.contentDisposition.toByteArray().size +
+ CRLF.size +
+ CONTENT_TYPE.size +
+ part.contentType.toByteArray().size +
+ CRLF.size +
+ CRLF.size +
+ contentLength +
+ CRLF.size
+ }
- override fun writeTo(outputStream: OutputStream) = entity.writeTo(outputStream)
+ byteCount += DASHDASH.size + boundaryBytes.size + DASHDASH.size + CRLF.size
+ return byteCount
+ }
- override fun contentType(): String = entity.contentType
+ override fun repeatable(): Boolean = parts.all { it.body.repeatable() }
- override fun contentLength(): Long = entity.contentLength
+ override fun close() {
+ parts.forEach { it.body.close() }
+ }
- override fun repeatable(): Boolean = entity.isRepeatable
+ class Builder {
+ private val boundary = UUID.randomUUID().toString()
+ private val parts: MutableList = mutableListOf()
- override fun close() = entity.close()
+ fun addPart(part: Part) = apply { parts.add(part) }
+
+ fun build() = MultipartBody(boundary, parts.toImmutable())
+ }
+
+ class Part
+ private constructor(
+ val contentDisposition: String,
+ val contentType: String,
+ val body: HttpRequestBody,
+ ) {
+ companion object {
+ fun create(
+ name: String,
+ filename: String?,
+ contentType: String,
+ body: HttpRequestBody,
+ ): Part {
+ val disposition = buildString {
+ append("form-data; name=")
+ appendQuotedString(name)
+ if (filename != null) {
+ append("; filename=")
+ appendQuotedString(filename)
+ }
+ }
+ return Part(disposition, contentType, body)
+ }
+ }
+ }
+
+ companion object {
+ private val CRLF = byteArrayOf('\r'.code.toByte(), '\n'.code.toByte())
+ private val DASHDASH = byteArrayOf('-'.code.toByte(), '-'.code.toByte())
+ private val CONTENT_DISPOSITION = "Content-Disposition: ".toByteArray()
+ private val CONTENT_TYPE = "Content-Type: ".toByteArray()
+
+ private fun StringBuilder.appendQuotedString(key: String) {
+ append('"')
+ for (ch in key) {
+ when (ch) {
+ '\n' -> append("%0A")
+ '\r' -> append("%0D")
+ '"' -> append("%22")
+ else -> append(ch)
+ }
+ }
+ append('"')
+ }
}
+}
diff --git a/sent-dm-java-core/src/main/kotlin/dm/sent/core/http/RetryingHttpClient.kt b/sent-dm-java-core/src/main/kotlin/dm/sent/core/http/RetryingHttpClient.kt
index 6a3fdc46..d2c5b882 100644
--- a/sent-dm-java-core/src/main/kotlin/dm/sent/core/http/RetryingHttpClient.kt
+++ b/sent-dm-java-core/src/main/kotlin/dm/sent/core/http/RetryingHttpClient.kt
@@ -1,3 +1,5 @@
+// File generated from our OpenAPI spec by Stainless.
+
package dm.sent.core.http
import dm.sent.core.DefaultSleeper
diff --git a/sent-dm-java-core/src/test/kotlin/dm/sent/TestServerExtension.kt b/sent-dm-java-core/src/test/kotlin/dm/sent/TestServerExtension.kt
deleted file mode 100644
index 07df7758..00000000
--- a/sent-dm-java-core/src/test/kotlin/dm/sent/TestServerExtension.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-package dm.sent
-
-import java.lang.RuntimeException
-import java.net.URL
-import org.junit.jupiter.api.extension.BeforeAllCallback
-import org.junit.jupiter.api.extension.ConditionEvaluationResult
-import org.junit.jupiter.api.extension.ExecutionCondition
-import org.junit.jupiter.api.extension.ExtensionContext
-
-class TestServerExtension : BeforeAllCallback, ExecutionCondition {
-
- override fun beforeAll(context: ExtensionContext?) {
- try {
- URL(BASE_URL).openConnection().connect()
- } catch (e: Exception) {
- throw RuntimeException(
- """
- The test suite will not run without a mock server running against your OpenAPI spec.
-
- You can set the environment variable `SKIP_MOCK_TESTS` to `true` to skip running any tests
- that require the mock server.
-
- To fix run `./scripts/mock` in a separate terminal.
- """
- .trimIndent(),
- e,
- )
- }
- }
-
- override fun evaluateExecutionCondition(context: ExtensionContext): ConditionEvaluationResult {
- return if (System.getenv(SKIP_TESTS_ENV).toBoolean()) {
- ConditionEvaluationResult.disabled(
- "Environment variable $SKIP_TESTS_ENV is set to true"
- )
- } else {
- ConditionEvaluationResult.enabled(
- "Environment variable $SKIP_TESTS_ENV is not set to true"
- )
- }
- }
-
- companion object {
-
- val BASE_URL = System.getenv("TEST_API_BASE_URL") ?: "http://localhost:4010"
-
- const val SKIP_TESTS_ENV: String = "SKIP_MOCK_TESTS"
- }
-}
diff --git a/sent-dm-java-core/src/test/kotlin/dm/sent/core/http/HttpRequestBodiesTest.kt b/sent-dm-java-core/src/test/kotlin/dm/sent/core/http/HttpRequestBodiesTest.kt
new file mode 100644
index 00000000..a3ffe24b
--- /dev/null
+++ b/sent-dm-java-core/src/test/kotlin/dm/sent/core/http/HttpRequestBodiesTest.kt
@@ -0,0 +1,729 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package dm.sent.core.http
+
+import dm.sent.core.MultipartField
+import dm.sent.core.jsonMapper
+import java.io.ByteArrayOutputStream
+import java.io.InputStream
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+
+internal class HttpRequestBodiesTest {
+
+ @Test
+ fun multipartFormData_serializesFieldWithFilename() {
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "file" to
+ MultipartField.builder()
+ .value("hello")
+ .filename("hello.txt")
+ .contentType("text/plain")
+ .build()
+ ),
+ )
+
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ assertThat(body.repeatable()).isTrue()
+ assertThat(output.size().toLong()).isEqualTo(body.contentLength())
+ val boundary = body.contentType()!!.substringAfter("multipart/form-data; boundary=")
+ assertThat(output.toString("UTF-8"))
+ .isEqualTo(
+ """
+ |--$boundary
+ |Content-Disposition: form-data; name="file"; filename="hello.txt"
+ |Content-Type: text/plain
+ |
+ |hello
+ |--$boundary--
+ |
+ """
+ .trimMargin()
+ .replace("\n", "\r\n")
+ )
+ }
+
+ @Test
+ fun multipartFormData_serializesFieldWithoutFilename() {
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "field" to
+ MultipartField.builder()
+ .value("value")
+ .contentType("text/plain")
+ .build()
+ ),
+ )
+
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ assertThat(body.repeatable()).isTrue()
+ assertThat(output.size().toLong()).isEqualTo(body.contentLength())
+ val boundary = boundary(body)
+ assertThat(output.toString("UTF-8"))
+ .isEqualTo(
+ """
+ |--$boundary
+ |Content-Disposition: form-data; name="field"
+ |Content-Type: text/plain
+ |
+ |value
+ |--$boundary--
+ |
+ """
+ .trimMargin()
+ .replace("\n", "\r\n")
+ )
+ }
+
+ @Test
+ fun multipartFormData_serializesInputStream() {
+ // Use `.buffered()` to get a non-ByteArrayInputStream, which hits the non-repeatable code
+ // path.
+ val inputStream = "stream content".byteInputStream().buffered()
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "data" to
+ MultipartField.builder()
+ .value(inputStream)
+ .contentType("application/octet-stream")
+ .build()
+ ),
+ )
+
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ assertThat(body.repeatable()).isFalse()
+ assertThat(body.contentLength()).isEqualTo(-1L)
+ val boundary = boundary(body)
+ assertThat(output.toString("UTF-8"))
+ .isEqualTo(
+ """
+ |--$boundary
+ |Content-Disposition: form-data; name="data"
+ |Content-Type: application/octet-stream
+ |
+ |stream content
+ |--$boundary--
+ |
+ """
+ .trimMargin()
+ .replace("\n", "\r\n")
+ )
+ }
+
+ @Test
+ fun multipartFormData_serializesByteArray() {
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "binary" to
+ MultipartField.builder()
+ .value("abc".toByteArray())
+ .contentType("application/octet-stream")
+ .build()
+ ),
+ )
+
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ assertThat(body.repeatable()).isTrue()
+ assertThat(body.contentLength()).isEqualTo(output.size().toLong())
+ val boundary = boundary(body)
+ assertThat(output.toString("UTF-8"))
+ .isEqualTo(
+ """
+ |--$boundary
+ |Content-Disposition: form-data; name="binary"
+ |Content-Type: application/octet-stream
+ |
+ |abc
+ |--$boundary--
+ |
+ """
+ .trimMargin()
+ .replace("\n", "\r\n")
+ )
+ }
+
+ @Test
+ fun multipartFormData_serializesBooleanValue() {
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "flag" to
+ MultipartField.builder()
+ .value(true)
+ .contentType("text/plain")
+ .build()
+ ),
+ )
+
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ assertThat(body.repeatable()).isTrue()
+ assertThat(body.contentLength()).isEqualTo(output.size().toLong())
+ val boundary = boundary(body)
+ assertThat(output.toString("UTF-8"))
+ .isEqualTo(
+ """
+ |--$boundary
+ |Content-Disposition: form-data; name="flag"
+ |Content-Type: text/plain
+ |
+ |true
+ |--$boundary--
+ |
+ """
+ .trimMargin()
+ .replace("\n", "\r\n")
+ )
+ }
+
+ @Test
+ fun multipartFormData_serializesNumberValue() {
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "count" to
+ MultipartField.builder().value(42).contentType("text/plain").build()
+ ),
+ )
+
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ assertThat(body.repeatable()).isTrue()
+ assertThat(body.contentLength()).isEqualTo(output.size().toLong())
+ val boundary = boundary(body)
+ assertThat(output.toString("UTF-8"))
+ .isEqualTo(
+ """
+ |--$boundary
+ |Content-Disposition: form-data; name="count"
+ |Content-Type: text/plain
+ |
+ |42
+ |--$boundary--
+ |
+ """
+ .trimMargin()
+ .replace("\n", "\r\n")
+ )
+ }
+
+ @Test
+ fun multipartFormData_serializesNullValueAsNoParts() {
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "present" to
+ MultipartField.builder()
+ .value("yes")
+ .contentType("text/plain")
+ .build(),
+ "absent" to
+ MultipartField.builder()
+ .value(null as String?)
+ .contentType("text/plain")
+ .build(),
+ ),
+ )
+
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ assertThat(body.repeatable()).isTrue()
+ assertThat(body.contentLength()).isEqualTo(output.size().toLong())
+ val boundary = boundary(body)
+ assertThat(output.toString("UTF-8"))
+ .isEqualTo(
+ """
+ |--$boundary
+ |Content-Disposition: form-data; name="present"
+ |Content-Type: text/plain
+ |
+ |yes
+ |--$boundary--
+ |
+ """
+ .trimMargin()
+ .replace("\n", "\r\n")
+ )
+ }
+
+ @Test
+ fun multipartFormData_serializesArray() {
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "items" to
+ MultipartField.builder>()
+ .value(listOf("alpha", "beta", "gamma"))
+ .contentType("text/plain")
+ .build()
+ ),
+ )
+
+ val output = ByteArrayOutputStream()
+ body.writeTo(output)
+
+ assertThat(body.repeatable()).isTrue()
+ assertThat(body.contentLength()).isEqualTo(output.size().toLong())
+ val boundary = boundary(body)
+ assertThat(output.toString("UTF-8"))
+ .isEqualTo(
+ """
+ |--$boundary
+ |Content-Disposition: form-data; name="items"
+ |Content-Type: text/plain
+ |
+ |alpha,beta,gamma
+ |--$boundary--
+ |
+ """
+ .trimMargin()
+ .replace("\n", "\r\n")
+ )
+ }
+
+ @Test
+ fun multipartFormData_serializesObjectAsNestedParts() {
+ val body =
+ multipartFormData(
+ jsonMapper(),
+ mapOf(
+ "meta" to
+ MultipartField.builder