diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 0056f0ab..ad56a78c 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "4.9.1"
+ ".": "4.10.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index 17749e26..f6897576 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 81
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/courier%2Fcourier-3fc1c86b4a83a16393aaf17d1fb3ac6098d30dd057ba872973b57285a7a3f0d0.yml
-openapi_spec_hash: 02a545d217b13399f311e99561f9de1d
-config_hash: 0789c3cddc625bb9712b3bded274ab6c
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/courier%2Fcourier-b3852cdd1020811766572923d26a6c95f4a538bf4c7268462b6ba39f34480b3e.yml
+openapi_spec_hash: b2e2f2357342e9a6b16a591d0b946e38
+config_hash: 6f072c60adb74a68d27be5a0e5ea3124
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d183d7d5..bf415330 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,34 @@
# Changelog
+## 4.10.0 (2026-02-25)
+
+Full Changelog: [v4.9.1...v4.10.0](https://github.com/trycourier/courier-java/compare/v4.9.1...v4.10.0)
+
+### Features
+
+* **client:** add connection pooling option ([b2e1bcb](https://github.com/trycourier/courier-java/commit/b2e1bcbd856bbe7d247497b2296dd8f368fdc6da))
+
+
+### Bug Fixes
+
+* **types:** remove brand field from ElementalContent model ([cb336a3](https://github.com/trycourier/courier-java/commit/cb336a3bb81b5e2bd25483be06c45bdfd518fe2b))
+
+
+### Chores
+
+* drop apache dependency ([90094b9](https://github.com/trycourier/courier-java/commit/90094b91bb84ab5f9b01d351031431990495e2fa))
+* **internal:** expand imports ([b1b8ef3](https://github.com/trycourier/courier-java/commit/b1b8ef384a3acddc0ce6cd79cfca840e6da4c748))
+* **internal:** make `OkHttp` constructor internal ([4e39320](https://github.com/trycourier/courier-java/commit/4e393200781dd186e45a32ff8d18be7083e83b31))
+* **internal:** remove mock server code ([b63945e](https://github.com/trycourier/courier-java/commit/b63945ee56673ba3e736de9d2170b0a27e7dcd82))
+* **internal:** update `TestServerExtension` comment ([26b5aed](https://github.com/trycourier/courier-java/commit/26b5aede00f47e7a0bae77e058e36a00361060b0))
+* make `Properties` more resilient to `null` ([401bc43](https://github.com/trycourier/courier-java/commit/401bc435edc5a6798afb1c1619a0de37cdb7a9fe))
+* update mock server docs ([ff62ef0](https://github.com/trycourier/courier-java/commit/ff62ef0c2c8c232f96a3b3e5ade9c6ac22a1b248))
+
+
+### Documentation
+
+* add AUTO-GENERATED-OVERVIEW markers for README sync ([#92](https://github.com/trycourier/courier-java/issues/92)) ([4bc2f46](https://github.com/trycourier/courier-java/commit/4bc2f4661c8c7d35d72234c1f5520eec71a39bcc))
+
## 4.9.1 (2026-02-07)
Full Changelog: [v4.9.0...v4.9.1](https://github.com/trycourier/courier-java/compare/v4.9.0...v4.9.1)
diff --git a/README.md b/README.md
index 1572f135..804ce850 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,8 @@
-[](https://central.sonatype.com/artifact/com.courier/courier-java/4.9.1)
-[](https://javadoc.io/doc/com.courier/courier-java/4.9.1)
+[](https://central.sonatype.com/artifact/com.courier/courier-java/4.10.0)
+[](https://javadoc.io/doc/com.courier/courier-java/4.10.0)
@@ -14,7 +14,7 @@ It is generated with [Stainless](https://www.stainless.com/).
-The REST API documentation can be found on [www.courier.com](https://www.courier.com/docs). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.courier/courier-java/4.9.1).
+The REST API documentation can be found on [www.courier.com](https://www.courier.com/docs). Javadocs are available on [javadoc.io](https://javadoc.io/doc/com.courier/courier-java/4.10.0).
@@ -25,7 +25,7 @@ The REST API documentation can be found on [www.courier.com](https://www.courier
### Gradle
```kotlin
-implementation("com.courier:courier-java:4.9.1")
+implementation("com.courier:courier-java:4.10.0")
```
### Maven
@@ -34,7 +34,7 @@ implementation("com.courier:courier-java:4.9.1")
com.courier
courier-java
- 4.9.1
+ 4.10.0
```
@@ -388,6 +388,25 @@ CourierClient client = CourierOkHttpClient.builder()
.build();
```
+### Connection pooling
+
+To customize the underlying OkHttp connection pool, configure the client using the `maxIdleConnections` and `keepAliveDuration` methods:
+
+```java
+import com.courier.client.CourierClient;
+import com.courier.client.okhttp.CourierOkHttpClient;
+import java.time.Duration;
+
+CourierClient client = CourierOkHttpClient.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 aae0be02..653fe829 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ repositories {
allprojects {
group = "com.courier"
- version = "4.9.1" // x-release-please-version
+ version = "4.10.0" // x-release-please-version
}
subprojects {
diff --git a/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/CourierOkHttpClient.kt b/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/CourierOkHttpClient.kt
index 429e095e..73be7c55 100644
--- a/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/CourierOkHttpClient.kt
+++ b/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/CourierOkHttpClient.kt
@@ -47,6 +47,8 @@ class CourierOkHttpClient 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 CourierOkHttpClient 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.
*
@@ -317,6 +359,8 @@ class CourierOkHttpClient private constructor() {
OkHttpClient.builder()
.timeout(clientOptions.timeout())
.proxy(proxy)
+ .maxIdleConnections(maxIdleConnections)
+ .keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
diff --git a/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/CourierOkHttpClientAsync.kt b/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/CourierOkHttpClientAsync.kt
index 2e2adb16..8a1baa2a 100644
--- a/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/CourierOkHttpClientAsync.kt
+++ b/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/CourierOkHttpClientAsync.kt
@@ -47,6 +47,8 @@ class CourierOkHttpClientAsync 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 CourierOkHttpClientAsync 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.
*
@@ -317,6 +359,8 @@ class CourierOkHttpClientAsync private constructor() {
OkHttpClient.builder()
.timeout(clientOptions.timeout())
.proxy(proxy)
+ .maxIdleConnections(maxIdleConnections)
+ .keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
diff --git a/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/OkHttpClient.kt b/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/OkHttpClient.kt
index ca6ffdbe..86340f02 100644
--- a/courier-java-client-okhttp/src/main/kotlin/com/courier/client/okhttp/OkHttpClient.kt
+++ b/courier-java-client-okhttp/src/main/kotlin/com/courier/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/courier-java-core/build.gradle.kts b/courier-java-core/build.gradle.kts
index 15e9f371..d4f0b6c8 100644
--- a/courier-java-core/build.gradle.kts
+++ b/courier-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(":courier-java-client-okhttp"))
diff --git a/courier-java-core/src/main/kotlin/com/courier/core/Properties.kt b/courier-java-core/src/main/kotlin/com/courier/core/Properties.kt
index f1fb24f6..20b78089 100644
--- a/courier-java-core/src/main/kotlin/com/courier/core/Properties.kt
+++ b/courier-java-core/src/main/kotlin/com/courier/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 =
- CourierClient::class.java.`package`.implementationVersion ?: "unknown"
+ CourierClient::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/courier-java-core/src/main/kotlin/com/courier/core/http/HttpRequestBodies.kt b/courier-java-core/src/main/kotlin/com/courier/core/http/HttpRequestBodies.kt
index 5736754c..c2ddbd06 100644
--- a/courier-java-core/src/main/kotlin/com/courier/core/http/HttpRequestBodies.kt
+++ b/courier-java-core/src/main/kotlin/com/courier/core/http/HttpRequestBodies.kt
@@ -5,16 +5,16 @@
package com.courier.core.http
import com.courier.core.MultipartField
+import com.courier.core.toImmutable
import com.courier.errors.CourierInvalidDataException
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.databind.node.JsonNodeType
+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 CourierInvalidDataException(
- "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 CourierInvalidDataException("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 CourierInvalidDataException(
+ "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 CourierInvalidDataException("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/courier-java-core/src/main/kotlin/com/courier/core/http/RetryingHttpClient.kt b/courier-java-core/src/main/kotlin/com/courier/core/http/RetryingHttpClient.kt
index 91211328..b41448f2 100644
--- a/courier-java-core/src/main/kotlin/com/courier/core/http/RetryingHttpClient.kt
+++ b/courier-java-core/src/main/kotlin/com/courier/core/http/RetryingHttpClient.kt
@@ -1,3 +1,5 @@
+// File generated from our OpenAPI spec by Stainless.
+
package com.courier.core.http
import com.courier.core.DefaultSleeper
diff --git a/courier-java-core/src/main/kotlin/com/courier/models/ElementalContent.kt b/courier-java-core/src/main/kotlin/com/courier/models/ElementalContent.kt
index ec80329c..e3659f80 100644
--- a/courier-java-core/src/main/kotlin/com/courier/models/ElementalContent.kt
+++ b/courier-java-core/src/main/kotlin/com/courier/models/ElementalContent.kt
@@ -16,7 +16,6 @@ import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
import java.util.Collections
import java.util.Objects
-import java.util.Optional
import kotlin.jvm.optionals.getOrNull
class ElementalContent
@@ -24,7 +23,6 @@ class ElementalContent
private constructor(
private val elements: JsonField>,
private val version: JsonField,
- private val brand: JsonField,
private val additionalProperties: MutableMap,
) {
@@ -34,8 +32,7 @@ private constructor(
@ExcludeMissing
elements: JsonField> = JsonMissing.of(),
@JsonProperty("version") @ExcludeMissing version: JsonField = JsonMissing.of(),
- @JsonProperty("brand") @ExcludeMissing brand: JsonField = JsonMissing.of(),
- ) : this(elements, version, brand, mutableMapOf())
+ ) : this(elements, version, mutableMapOf())
/**
* @throws CourierInvalidDataException if the JSON field has an unexpected type or is
@@ -51,12 +48,6 @@ private constructor(
*/
fun version(): String = version.getRequired("version")
- /**
- * @throws CourierInvalidDataException if the JSON field has an unexpected type (e.g. if the
- * server responded with an unexpected value).
- */
- fun brand(): Optional = brand.getOptional("brand")
-
/**
* Returns the raw JSON value of [elements].
*
@@ -73,13 +64,6 @@ private constructor(
*/
@JsonProperty("version") @ExcludeMissing fun _version(): JsonField = version
- /**
- * Returns the raw JSON value of [brand].
- *
- * Unlike [brand], this method doesn't throw if the JSON field has an unexpected type.
- */
- @JsonProperty("brand") @ExcludeMissing fun _brand(): JsonField = brand
-
@JsonAnySetter
private fun putAdditionalProperty(key: String, value: JsonValue) {
additionalProperties.put(key, value)
@@ -111,14 +95,12 @@ private constructor(
private var elements: JsonField>? = null
private var version: JsonField? = null
- private var brand: JsonField = JsonMissing.of()
private var additionalProperties: MutableMap = mutableMapOf()
@JvmSynthetic
internal fun from(elementalContent: ElementalContent) = apply {
elements = elementalContent.elements.map { it.toMutableList() }
version = elementalContent.version
- brand = elementalContent.brand
additionalProperties = elementalContent.additionalProperties.toMutableMap()
}
@@ -205,19 +187,6 @@ private constructor(
*/
fun version(version: JsonField) = apply { this.version = version }
- fun brand(brand: String?) = brand(JsonField.ofNullable(brand))
-
- /** Alias for calling [Builder.brand] with `brand.orElse(null)`. */
- fun brand(brand: Optional) = brand(brand.getOrNull())
-
- /**
- * Sets [Builder.brand] to an arbitrary JSON value.
- *
- * You should usually call [Builder.brand] with a well-typed [String] value instead. This
- * method is primarily for setting the field to an undocumented or not yet supported value.
- */
- fun brand(brand: JsonField) = apply { this.brand = brand }
-
fun additionalProperties(additionalProperties: Map) = apply {
this.additionalProperties.clear()
putAllAdditionalProperties(additionalProperties)
@@ -254,7 +223,6 @@ private constructor(
ElementalContent(
checkRequired("elements", elements).map { it.toImmutable() },
checkRequired("version", version),
- brand,
additionalProperties.toMutableMap(),
)
}
@@ -268,7 +236,6 @@ private constructor(
elements().forEach { it.validate() }
version()
- brand()
validated = true
}
@@ -288,8 +255,7 @@ private constructor(
@JvmSynthetic
internal fun validity(): Int =
(elements.asKnown().getOrNull()?.sumOf { it.validity().toInt() } ?: 0) +
- (if (version.asKnown().isPresent) 1 else 0) +
- (if (brand.asKnown().isPresent) 1 else 0)
+ (if (version.asKnown().isPresent) 1 else 0)
override fun equals(other: Any?): Boolean {
if (this === other) {
@@ -299,16 +265,13 @@ private constructor(
return other is ElementalContent &&
elements == other.elements &&
version == other.version &&
- brand == other.brand &&
additionalProperties == other.additionalProperties
}
- private val hashCode: Int by lazy {
- Objects.hash(elements, version, brand, additionalProperties)
- }
+ private val hashCode: Int by lazy { Objects.hash(elements, version, additionalProperties) }
override fun hashCode(): Int = hashCode
override fun toString() =
- "ElementalContent{elements=$elements, version=$version, brand=$brand, additionalProperties=$additionalProperties}"
+ "ElementalContent{elements=$elements, version=$version, additionalProperties=$additionalProperties}"
}
diff --git a/courier-java-core/src/test/kotlin/com/courier/TestServerExtension.kt b/courier-java-core/src/test/kotlin/com/courier/TestServerExtension.kt
deleted file mode 100644
index b9615827..00000000
--- a/courier-java-core/src/test/kotlin/com/courier/TestServerExtension.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.courier
-
-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 Prism 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:
-
- 1. Install Prism (requires Node 16+):
-
- With npm:
- $ npm install -g @stoplight/prism-cli
-
- With yarn:
- $ yarn global add @stoplight/prism-cli
-
- 2. Run the mock server
-
- To run the server, pass in the path of your OpenAPI spec to the prism command:
- $ prism mock path/to/your.openapi.yml
- """
- .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/courier-java-core/src/test/kotlin/com/courier/core/http/HttpRequestBodiesTest.kt b/courier-java-core/src/test/kotlin/com/courier/core/http/HttpRequestBodiesTest.kt
new file mode 100644
index 00000000..2c91aef2
--- /dev/null
+++ b/courier-java-core/src/test/kotlin/com/courier/core/http/HttpRequestBodiesTest.kt
@@ -0,0 +1,729 @@
+// File generated from our OpenAPI spec by Stainless.
+
+package com.courier.core.http
+
+import com.courier.core.MultipartField
+import com.courier.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