diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 86b0e83d..cb9d2541 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.21.0" + ".": "0.22.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2a23b543..c4ea5b74 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 40 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sent/sent-dm-f4ae64eeaadd325d2231bc652be45820d6c3f05960290e3f366feb588c9d0595.yml -openapi_spec_hash: ce42bb3c56b8d53e994291b106fb3867 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sent/sent-dm-7bb26574b68a4a83fda65dd095f3f54fc797d44988eb3ed8b72f6f1be768d284.yml +openapi_spec_hash: 92349dc439d33c6d4bd2a467a36a6190 config_hash: 7fe4b7f38470a511342b783de698aa99 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ddebb70..0819fd41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.22.0 (2026-05-06) + +Full Changelog: [v0.21.0...v0.22.0](https://github.com/sentdm/sent-dm-java/compare/v0.21.0...v0.22.0) + +### Features + +* **client:** support proxy authentication ([9aa3cbc](https://github.com/sentdm/sent-dm-java/commit/9aa3cbc4241db013098568f6eb16f60937ec7f75)) + ## 0.21.0 (2026-05-05) Full Changelog: [v0.20.0...v0.21.0](https://github.com/sentdm/sent-dm-java/compare/v0.20.0...v0.21.0) diff --git a/README.md b/README.md index 1c2b53a2..f272d7ce 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ -[![Maven Central](https://img.shields.io/maven-central/v/dm.sent/sent-java)](https://central.sonatype.com/artifact/dm.sent/sent-java/0.21.0) -[![javadoc](https://javadoc.io/badge2/dm.sent/sent-java/0.21.0/javadoc.svg)](https://javadoc.io/doc/dm.sent/sent-java/0.21.0) +[![Maven Central](https://img.shields.io/maven-central/v/dm.sent/sent-java)](https://central.sonatype.com/artifact/dm.sent/sent-java/0.22.0) +[![javadoc](https://javadoc.io/badge2/dm.sent/sent-java/0.22.0/javadoc.svg)](https://javadoc.io/doc/dm.sent/sent-java/0.22.0) @@ -22,7 +22,7 @@ Use the Sent MCP Server to enable AI assistants to interact with this API, allow -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-java/0.21.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-java/0.22.0). @@ -33,7 +33,7 @@ The REST API documentation can be found on [docs.sent.dm](https://docs.sent.dm). ### Gradle ```kotlin -implementation("dm.sent:sent-java:0.21.0") +implementation("dm.sent:sent-java:0.22.0") ``` ### Maven @@ -42,7 +42,7 @@ implementation("dm.sent:sent-java:0.21.0") dm.sent sent-java - 0.21.0 + 0.22.0 ``` @@ -400,6 +400,21 @@ SentClient client = SentOkHttpClient.builder() .build(); ``` +If the proxy responds with `407 Proxy Authentication Required`, supply credentials by also configuring `proxyAuthenticator`: + +```java +import dm.sent.client.SentClient; +import dm.sent.client.okhttp.SentOkHttpClient; +import dm.sent.core.http.ProxyAuthenticator; + +SentClient client = SentOkHttpClient.builder() + .fromEnv() + .proxy(...) + // Or a custom implementation of `ProxyAuthenticator`. + .proxyAuthenticator(ProxyAuthenticator.basic("username", "password")) + .build(); +``` + ### Connection pooling To customize the underlying OkHttp connection pool, configure the client using the `maxIdleConnections` and `keepAliveDuration` methods: diff --git a/build.gradle.kts b/build.gradle.kts index 6838060b..b34869e5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ repositories { allprojects { group = "dm.sent" - version = "0.21.0" // x-release-please-version + version = "0.22.0" // x-release-please-version } subprojects { diff --git a/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/OkHttpClient.kt b/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/OkHttpClient.kt index 998f5876..5110f815 100644 --- a/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/OkHttpClient.kt +++ b/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/OkHttpClient.kt @@ -8,9 +8,11 @@ import dm.sent.core.http.HttpMethod import dm.sent.core.http.HttpRequest import dm.sent.core.http.HttpRequestBody import dm.sent.core.http.HttpResponse +import dm.sent.core.http.ProxyAuthenticator import dm.sent.errors.SentIoException import java.io.IOException import java.io.InputStream +import java.io.OutputStream import java.net.Proxy import java.time.Duration import java.util.concurrent.CancellationException @@ -20,10 +22,12 @@ import java.util.concurrent.TimeUnit import javax.net.ssl.HostnameVerifier import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import kotlin.jvm.optionals.getOrNull import okhttp3.Call import okhttp3.Callback import okhttp3.ConnectionPool import okhttp3.Dispatcher +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType @@ -33,6 +37,8 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import okio.BufferedSink +import okio.buffer +import okio.sink class OkHttpClient internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClient) : HttpClient { @@ -41,7 +47,7 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie val call = newCall(request, requestOptions) return try { - call.execute().toResponse() + call.execute().toHttpResponse() } catch (e: IOException) { throw SentIoException("Request failed", e) } finally { @@ -59,7 +65,7 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie call.enqueue( object : Callback { override fun onResponse(call: Call, response: Response) { - future.complete(response.toResponse()) + future.complete(response.toHttpResponse()) } override fun onFailure(call: Call, e: IOException) { @@ -111,89 +117,6 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie return client.newCall(request.toRequest(client)) } - private fun HttpRequest.toRequest(client: okhttp3.OkHttpClient): Request { - var body: RequestBody? = body?.toRequestBody() - if (body == null && requiresBody(method)) { - body = "".toRequestBody() - } - - val builder = Request.Builder().url(toUrl()).method(method.name, body) - headers.names().forEach { name -> - headers.values(name).forEach { builder.addHeader(name, it) } - } - - if ( - !headers.names().contains("X-Stainless-Read-Timeout") && client.readTimeoutMillis != 0 - ) { - builder.addHeader( - "X-Stainless-Read-Timeout", - Duration.ofMillis(client.readTimeoutMillis.toLong()).seconds.toString(), - ) - } - if (!headers.names().contains("X-Stainless-Timeout") && client.callTimeoutMillis != 0) { - builder.addHeader( - "X-Stainless-Timeout", - Duration.ofMillis(client.callTimeoutMillis.toLong()).seconds.toString(), - ) - } - - return builder.build() - } - - /** `OkHttpClient` always requires a request body for some methods. */ - private fun requiresBody(method: HttpMethod): Boolean = - when (method) { - HttpMethod.POST, - HttpMethod.PUT, - HttpMethod.PATCH -> true - else -> false - } - - private fun HttpRequest.toUrl(): String { - val builder = baseUrl.toHttpUrl().newBuilder() - pathSegments.forEach(builder::addPathSegment) - queryParams.keys().forEach { key -> - queryParams.values(key).forEach { builder.addQueryParameter(key, it) } - } - - return builder.toString() - } - - private fun HttpRequestBody.toRequestBody(): RequestBody { - val mediaType = contentType()?.toMediaType() - val length = contentLength() - - return object : RequestBody() { - override fun contentType(): MediaType? = mediaType - - override fun contentLength(): Long = length - - override fun isOneShot(): Boolean = !repeatable() - - override fun writeTo(sink: BufferedSink) = writeTo(sink.outputStream()) - } - } - - private fun Response.toResponse(): HttpResponse { - val headers = headers.toHeaders() - - return object : HttpResponse { - override fun statusCode(): Int = code - - override fun headers(): Headers = headers - - override fun body(): InputStream = body!!.byteStream() - - override fun close() = body!!.close() - } - } - - private fun okhttp3.Headers.toHeaders(): Headers { - val headersBuilder = Headers.builder() - forEach { (name, value) -> headersBuilder.put(name, value) } - return headersBuilder.build() - } - companion object { @JvmStatic fun builder() = Builder() } @@ -202,6 +125,7 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie private var timeout: Timeout = Timeout.default() private var proxy: Proxy? = null + private var proxyAuthenticator: ProxyAuthenticator? = null private var maxIdleConnections: Int? = null private var keepAliveDuration: Duration? = null private var dispatcherExecutorService: ExecutorService? = null @@ -215,6 +139,10 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie fun proxy(proxy: Proxy?) = apply { this.proxy = proxy } + fun proxyAuthenticator(proxyAuthenticator: ProxyAuthenticator?) = apply { + this.proxyAuthenticator = proxyAuthenticator + } + /** * Sets the maximum number of idle connections kept by the underlying [ConnectionPool]. * @@ -264,6 +192,19 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie .callTimeout(timeout.request()) .proxy(proxy) .apply { + proxyAuthenticator?.let { auth -> + proxyAuthenticator { route, response -> + auth + .authenticate( + route?.proxy ?: Proxy.NO_PROXY, + response.request.toHttpRequest(), + response.toHttpResponse(), + ) + .getOrNull() + ?.toRequest(client = null) + } + } + dispatcherExecutorService?.let { dispatcher(Dispatcher(it)) } val maxIdleConnections = maxIdleConnections @@ -303,3 +244,126 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie ) } } + +private fun HttpRequest.toRequest(client: okhttp3.OkHttpClient?): Request { + var body: RequestBody? = body?.toRequestBody() + if (body == null && requiresBody(method)) { + body = "".toRequestBody() + } + + val builder = Request.Builder().url(toUrl()).method(method.name, body) + headers.names().forEach { name -> headers.values(name).forEach { builder.addHeader(name, it) } } + + if (client != null) { + if ( + !headers.names().contains("X-Stainless-Read-Timeout") && client.readTimeoutMillis != 0 + ) { + builder.addHeader( + "X-Stainless-Read-Timeout", + Duration.ofMillis(client.readTimeoutMillis.toLong()).seconds.toString(), + ) + } + if (!headers.names().contains("X-Stainless-Timeout") && client.callTimeoutMillis != 0) { + builder.addHeader( + "X-Stainless-Timeout", + Duration.ofMillis(client.callTimeoutMillis.toLong()).seconds.toString(), + ) + } + } + + return builder.build() +} + +/** `OkHttpClient` always requires a request body for some methods. */ +private fun requiresBody(method: HttpMethod): Boolean = + when (method) { + HttpMethod.POST, + HttpMethod.PUT, + HttpMethod.PATCH -> true + else -> false + } + +private fun HttpRequest.toUrl(): String { + val builder = baseUrl.toHttpUrl().newBuilder() + pathSegments.forEach(builder::addPathSegment) + queryParams.keys().forEach { key -> + queryParams.values(key).forEach { builder.addQueryParameter(key, it) } + } + + return builder.toString() +} + +private fun HttpRequestBody.toRequestBody(): RequestBody { + val mediaType = contentType()?.toMediaType() + val length = contentLength() + + return object : RequestBody() { + override fun contentType(): MediaType? = mediaType + + override fun contentLength(): Long = length + + override fun isOneShot(): Boolean = !repeatable() + + override fun writeTo(sink: BufferedSink) = writeTo(sink.outputStream()) + } +} + +private fun Request.toHttpRequest(): HttpRequest { + val builder = HttpRequest.builder().method(HttpMethod.valueOf(method)).baseUrl(url.toBaseUrl()) + url.pathSegments.forEach(builder::addPathSegment) + url.queryParameterNames.forEach { name -> + url.queryParameterValues(name).filterNotNull().forEach { builder.putQueryParam(name, it) } + } + headers.forEach { (name, value) -> builder.putHeader(name, value) } + body?.let { builder.body(it.toHttpRequestBody()) } + return builder.build() +} + +private fun HttpUrl.toBaseUrl(): String = buildString { + append(scheme).append("://").append(host) + if (port != HttpUrl.defaultPort(scheme)) { + append(":").append(port) + } +} + +private fun RequestBody.toHttpRequestBody(): HttpRequestBody { + val mediaType = contentType()?.toString() + val length = contentLength() + val isOneShot = isOneShot() + val source = this + return object : HttpRequestBody { + override fun contentType(): String? = mediaType + + override fun contentLength(): Long = length + + override fun repeatable(): Boolean = !isOneShot + + override fun writeTo(outputStream: OutputStream) { + val sink = outputStream.sink().buffer() + source.writeTo(sink) + sink.flush() + } + + override fun close() {} + } +} + +private fun Response.toHttpResponse(): HttpResponse { + val headers = headers.toHeaders() + + return object : HttpResponse { + override fun statusCode(): Int = code + + override fun headers(): Headers = headers + + override fun body(): InputStream = body!!.byteStream() + + override fun close() = body!!.close() + } +} + +private fun okhttp3.Headers.toHeaders(): Headers { + val headersBuilder = Headers.builder() + forEach { (name, value) -> headersBuilder.put(name, value) } + return headersBuilder.build() +} diff --git a/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentOkHttpClient.kt b/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentOkHttpClient.kt index b78da9b4..6b008c7a 100644 --- a/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentOkHttpClient.kt +++ b/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentOkHttpClient.kt @@ -10,6 +10,7 @@ import dm.sent.core.Sleeper import dm.sent.core.Timeout import dm.sent.core.http.Headers import dm.sent.core.http.HttpClient +import dm.sent.core.http.ProxyAuthenticator import dm.sent.core.http.QueryParams import dm.sent.core.jsonMapper import java.net.Proxy @@ -47,6 +48,7 @@ class SentOkHttpClient private constructor() { private var clientOptions: ClientOptions.Builder = ClientOptions.builder() private var dispatcherExecutorService: ExecutorService? = null private var proxy: Proxy? = null + private var proxyAuthenticator: ProxyAuthenticator? = null private var maxIdleConnections: Int? = null private var keepAliveDuration: Duration? = null private var sslSocketFactory: SSLSocketFactory? = null @@ -77,6 +79,20 @@ class SentOkHttpClient private constructor() { /** Alias for calling [Builder.proxy] with `proxy.orElse(null)`. */ fun proxy(proxy: Optional) = proxy(proxy.getOrNull()) + /** + * Provides credentials when an HTTP proxy responds with `407 Proxy Authentication + * Required`. + */ + fun proxyAuthenticator(proxyAuthenticator: ProxyAuthenticator?) = apply { + this.proxyAuthenticator = proxyAuthenticator + } + + /** + * Alias for calling [Builder.proxyAuthenticator] with `proxyAuthenticator.orElse(null)`. + */ + fun proxyAuthenticator(proxyAuthenticator: Optional) = + proxyAuthenticator(proxyAuthenticator.getOrNull()) + /** * The maximum number of idle connections kept by the underlying OkHttp connection pool. * @@ -366,6 +382,7 @@ class SentOkHttpClient private constructor() { OkHttpClient.builder() .timeout(clientOptions.timeout()) .proxy(proxy) + .proxyAuthenticator(proxyAuthenticator) .maxIdleConnections(maxIdleConnections) .keepAliveDuration(keepAliveDuration) .dispatcherExecutorService(dispatcherExecutorService) diff --git a/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentOkHttpClientAsync.kt b/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentOkHttpClientAsync.kt index 4eea336e..9743ffb5 100644 --- a/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentOkHttpClientAsync.kt +++ b/sent-java-client-okhttp/src/main/kotlin/dm/sent/client/okhttp/SentOkHttpClientAsync.kt @@ -10,6 +10,7 @@ import dm.sent.core.Sleeper import dm.sent.core.Timeout import dm.sent.core.http.Headers import dm.sent.core.http.HttpClient +import dm.sent.core.http.ProxyAuthenticator import dm.sent.core.http.QueryParams import dm.sent.core.jsonMapper import java.net.Proxy @@ -47,6 +48,7 @@ class SentOkHttpClientAsync private constructor() { private var clientOptions: ClientOptions.Builder = ClientOptions.builder() private var dispatcherExecutorService: ExecutorService? = null private var proxy: Proxy? = null + private var proxyAuthenticator: ProxyAuthenticator? = null private var maxIdleConnections: Int? = null private var keepAliveDuration: Duration? = null private var sslSocketFactory: SSLSocketFactory? = null @@ -77,6 +79,20 @@ class SentOkHttpClientAsync private constructor() { /** Alias for calling [Builder.proxy] with `proxy.orElse(null)`. */ fun proxy(proxy: Optional) = proxy(proxy.getOrNull()) + /** + * Provides credentials when an HTTP proxy responds with `407 Proxy Authentication + * Required`. + */ + fun proxyAuthenticator(proxyAuthenticator: ProxyAuthenticator?) = apply { + this.proxyAuthenticator = proxyAuthenticator + } + + /** + * Alias for calling [Builder.proxyAuthenticator] with `proxyAuthenticator.orElse(null)`. + */ + fun proxyAuthenticator(proxyAuthenticator: Optional) = + proxyAuthenticator(proxyAuthenticator.getOrNull()) + /** * The maximum number of idle connections kept by the underlying OkHttp connection pool. * @@ -366,6 +382,7 @@ class SentOkHttpClientAsync private constructor() { OkHttpClient.builder() .timeout(clientOptions.timeout()) .proxy(proxy) + .proxyAuthenticator(proxyAuthenticator) .maxIdleConnections(maxIdleConnections) .keepAliveDuration(keepAliveDuration) .dispatcherExecutorService(dispatcherExecutorService) diff --git a/sent-java-core/src/main/kotlin/dm/sent/core/http/ProxyAuthenticator.kt b/sent-java-core/src/main/kotlin/dm/sent/core/http/ProxyAuthenticator.kt new file mode 100644 index 00000000..1c460c27 --- /dev/null +++ b/sent-java-core/src/main/kotlin/dm/sent/core/http/ProxyAuthenticator.kt @@ -0,0 +1,59 @@ +package dm.sent.core.http + +import java.net.Proxy +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.Optional + +/** + * Provides credentials when an HTTP proxy responds with `407 Proxy Authentication Required`. + * + * Implementations inspect the 407 [response] (typically its `Proxy-Authenticate` header) and return + * the request to retry with a `Proxy-Authorization` header set, or [Optional.empty] to abandon + * authentication and surface the 407 to the caller. + * + * Implementations must be thread-safe; they may be invoked concurrently from multiple HTTP calls. + */ +fun interface ProxyAuthenticator { + + /** + * @param proxy the proxy that produced the challenge, or [Proxy.NO_PROXY] if the route is not + * yet established + * @param request the request that produced [response] + * @param response the 407 challenge response + * @return the retry request to send (typically [request] with a `Proxy-Authorization` header + * added), or [Optional.empty] to abandon authentication + */ + fun authenticate( + proxy: Proxy, + request: HttpRequest, + response: HttpResponse, + ): Optional + + companion object { + + /** + * A [ProxyAuthenticator] that uses RFC 7617 Basic authentication with the ISO-8859-1 + * charset. + */ + @JvmStatic + fun basic(username: String, password: String): ProxyAuthenticator = + basic(username, password, StandardCharsets.ISO_8859_1) + + /** + * A [ProxyAuthenticator] that uses RFC 7617 Basic authentication with the given [charset]. + */ + @JvmStatic + fun basic(username: String, password: String, charset: Charset): ProxyAuthenticator { + val token = + Base64.getEncoder().encodeToString("$username:$password".toByteArray(charset)) + val headerValue = "Basic $token" + return ProxyAuthenticator { _, request, _ -> + Optional.of( + request.toBuilder().putHeader("Proxy-Authorization", headerValue).build() + ) + } + } + } +}