diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0b5e4c3..ea98b61f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,10 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: | @@ -47,10 +47,10 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: | @@ -67,7 +67,7 @@ jobs: - name: Get GitHub OIDC Token if: github.repository == 'stainless-sdks/courier-java' id: github-oidc - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); @@ -85,10 +85,10 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/courier-java' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: | diff --git a/.github/workflows/publish-sonatype.yml b/.github/workflows/publish-sonatype.yml index 48bee9c5..800d5266 100644 --- a/.github/workflows/publish-sonatype.yml +++ b/.github/workflows/publish-sonatype.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index ba006aee..2cc67736 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'trycourier/courier-java' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f1a48d37..90eeef65 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.7.1" + ".": "4.8.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 92aa6915..edd99bd4 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 78 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/courier%2Fcourier-e3e54d99e2a73fd87519270f2685131050d342e86a4e96130247b854deae5c20.yml -openapi_spec_hash: 897a3fbee24f24d021d6af0df480220c -config_hash: 66a5c28bb74d78454456d9ce7d1c0a0c +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/courier%2Fcourier-4469c7d243ac17a71d48187ede11d7f6fd178d1006f2542c973259c5c37007fb.yml +openapi_spec_hash: 2036a46b6fa7ac8eae981583bd452458 +config_hash: 93eb861d9572cea4d66edeab309e08c6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 20855c1d..429953fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## 4.8.0 (2026-01-27) + +Full Changelog: [v4.7.1...v4.8.0](https://github.com/trycourier/courier-java/compare/v4.7.1...v4.8.0) + +### Features + +* **client:** send `X-Stainless-Kotlin-Version` header ([11e5765](https://github.com/trycourier/courier-java/commit/11e5765a138fa78d8aaf0abc3df4dffd1493d3b4)) + + +### Bug Fixes + +* **client:** disallow coercion from float to int ([5263a13](https://github.com/trycourier/courier-java/commit/5263a1385622f70224b69681b7f75661d89107ef)) +* **client:** fully respect max retries ([33cdc16](https://github.com/trycourier/courier-java/commit/33cdc16ed453f885b62c996480751a45035e0afd)) +* **client:** preserve time zone in lenient date-time parsing ([07887c9](https://github.com/trycourier/courier-java/commit/07887c919084244ae3805068e13aa7c7a6604aec)) +* **client:** send retry count header for max retries 0 ([33cdc16](https://github.com/trycourier/courier-java/commit/33cdc16ed453f885b62c996480751a45035e0afd)) +* date time deserialization leniency ([fa50329](https://github.com/trycourier/courier-java/commit/fa50329d5f5e1cd2c555d5deb71b4dd4d8a57c4a)) + + +### Chores + +* **ci:** upgrade `actions/github-script` ([b428743](https://github.com/trycourier/courier-java/commit/b428743ceed1201f44ca1ae23ddae0c10a323a86)) +* **ci:** upgrade `actions/setup-java` ([01049af](https://github.com/trycourier/courier-java/commit/01049af97f49ca7d1ad74d09017838263e24735f)) +* **internal:** clean up maven repo artifact script and add html documentation to repo root ([e1937ab](https://github.com/trycourier/courier-java/commit/e1937aba76551a3e486cb8961c3f1e0981d9a57f)) +* **internal:** correct cache invalidation for `SKIP_MOCK_TESTS` ([3bc2faa](https://github.com/trycourier/courier-java/commit/3bc2faa7b3d346e8c4466a58039947f2cebe04ca)) +* **internal:** depend on packages directly in example ([33cdc16](https://github.com/trycourier/courier-java/commit/33cdc16ed453f885b62c996480751a45035e0afd)) +* **internal:** improve maven repo docs ([af0c83b](https://github.com/trycourier/courier-java/commit/af0c83b582d3d26589a0f1176405a365df1e26b5)) +* **internal:** update `actions/checkout` version ([337fcd7](https://github.com/trycourier/courier-java/commit/337fcd7a6f5f7d5747ab6f48e74287e58d18eca0)) +* **internal:** update maven repo doc to include authentication ([dbf1829](https://github.com/trycourier/courier-java/commit/dbf182983acea01de5784fe9df85f1d43482de5a)) +* test on Jackson 2.14.0 to avoid encountering FasterXML/jackson-databind[#3240](https://github.com/trycourier/courier-java/issues/3240) in tests ([fa50329](https://github.com/trycourier/courier-java/commit/fa50329d5f5e1cd2c555d5deb71b4dd4d8a57c4a)) + + +### Documentation + +* add comment for arbitrary value fields ([20bc1ec](https://github.com/trycourier/courier-java/commit/20bc1ecfa8818a7f6eb6669352d74306b308cf21)) +* clarify version field description in AutomationTemplate ([d853521](https://github.com/trycourier/courier-java/commit/d8535218c37e9315c2bbe6b5161caaa2d66fb603)) + ## 4.7.1 (2026-01-14) Full Changelog: [v4.7.0...v4.7.1](https://github.com/trycourier/courier-java/compare/v4.7.0...v4.7.1) diff --git a/README.md b/README.md index 47bcf43a..a85bd3fe 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ -[![Maven Central](https://img.shields.io/maven-central/v/com.courier/courier-java)](https://central.sonatype.com/artifact/com.courier/courier-java/4.7.1) -[![javadoc](https://javadoc.io/badge2/com.courier/courier-java/4.7.1/javadoc.svg)](https://javadoc.io/doc/com.courier/courier-java/4.7.1) +[![Maven Central](https://img.shields.io/maven-central/v/com.courier/courier-java)](https://central.sonatype.com/artifact/com.courier/courier-java/4.8.0) +[![javadoc](https://javadoc.io/badge2/com.courier/courier-java/4.8.0/javadoc.svg)](https://javadoc.io/doc/com.courier/courier-java/4.8.0) @@ -13,7 +13,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.7.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.8.0). @@ -24,7 +24,7 @@ The REST API documentation can be found on [www.courier.com](https://www.courier ### Gradle ```kotlin -implementation("com.courier:courier-java:4.7.1") +implementation("com.courier:courier-java:4.8.0") ``` ### Maven @@ -33,7 +33,7 @@ implementation("com.courier:courier-java:4.7.1") com.courier courier-java - 4.7.1 + 4.8.0 ``` @@ -310,6 +310,8 @@ If the SDK threw an exception, but you're _certain_ the version is compatible, t > [!CAUTION] > We make no guarantee that the SDK works correctly when the Jackson version check is disabled. +Also note that there are bugs in older Jackson versions that can affect the SDK. We don't work around all Jackson bugs ([example](https://github.com/FasterXML/jackson-databind/issues/3240)) and expect users to upgrade Jackson for those instead. + ## Network options ### Retries diff --git a/build.gradle.kts b/build.gradle.kts index 885270a0..3e741ebc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ repositories { allprojects { group = "com.courier" - version = "4.7.1" // x-release-please-version + version = "4.8.0" // x-release-please-version } subprojects { diff --git a/buildSrc/src/main/kotlin/courier.kotlin.gradle.kts b/buildSrc/src/main/kotlin/courier.kotlin.gradle.kts index 365ccefa..9de64f25 100644 --- a/buildSrc/src/main/kotlin/courier.kotlin.gradle.kts +++ b/buildSrc/src/main/kotlin/courier.kotlin.gradle.kts @@ -33,6 +33,9 @@ kotlin { tasks.withType().configureEach { systemProperty("junit.jupiter.execution.parallel.enabled", true) systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent") + + // `SKIP_MOCK_TESTS` affects which tests run so it must be added as input for proper cache invalidation. + inputs.property("skipMockTests", System.getenv("SKIP_MOCK_TESTS")).optional(true) } val ktfmt by configurations.creating 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 79595a1b..ca6ffdbe 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 @@ -230,6 +230,8 @@ private constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClien fun build(): OkHttpClient = OkHttpClient( okhttp3.OkHttpClient.Builder() + // `RetryingHttpClient` handles retries if the user enabled them. + .retryOnConnectionFailure(false) .connectTimeout(timeout.connect()) .readTimeout(timeout.read()) .writeTimeout(timeout.write()) diff --git a/courier-java-core/build.gradle.kts b/courier-java-core/build.gradle.kts index d31e4b11..3396f586 100644 --- a/courier-java-core/build.gradle.kts +++ b/courier-java-core/build.gradle.kts @@ -5,14 +5,16 @@ plugins { configurations.all { resolutionStrategy { - // Compile and test against a lower Jackson version to ensure we're compatible with it. - // We publish with a higher version (see below) to ensure users depend on a secure version by default. - force("com.fasterxml.jackson.core:jackson-core:2.13.4") - force("com.fasterxml.jackson.core:jackson-databind:2.13.4") - force("com.fasterxml.jackson.core:jackson-annotations:2.13.4") - force("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.13.4") - force("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.4") - force("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.4") + // Compile and test against a lower Jackson version to ensure we're compatible with it. Note that + // we generally support 2.13.4, but test against 2.14.0 because 2.13.4 has some annoying (but + // niche) bugs (users should upgrade if they encounter them). We publish with a higher version + // (see below) to ensure users depend on a secure version by default. + force("com.fasterxml.jackson.core:jackson-core:2.14.0") + force("com.fasterxml.jackson.core:jackson-databind:2.14.0") + force("com.fasterxml.jackson.core:jackson-annotations:2.14.0") + force("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.14.0") + force("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.14.0") + force("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.0") } } diff --git a/courier-java-core/src/main/kotlin/com/courier/core/ClientOptions.kt b/courier-java-core/src/main/kotlin/com/courier/core/ClientOptions.kt index f9dc8443..122fde64 100644 --- a/courier-java-core/src/main/kotlin/com/courier/core/ClientOptions.kt +++ b/courier-java-core/src/main/kotlin/com/courier/core/ClientOptions.kt @@ -402,6 +402,7 @@ private constructor( headers.put("X-Stainless-Package-Version", getPackageVersion()) headers.put("X-Stainless-Runtime", "JRE") headers.put("X-Stainless-Runtime-Version", getJavaVersion()) + headers.put("X-Stainless-Kotlin-Version", KotlinVersion.CURRENT.toString()) apiKey.let { if (!it.isEmpty()) { headers.put("Authorization", "Bearer $it") diff --git a/courier-java-core/src/main/kotlin/com/courier/core/ObjectMappers.kt b/courier-java-core/src/main/kotlin/com/courier/core/ObjectMappers.kt index a2cc05b6..feeda7fe 100644 --- a/courier-java-core/src/main/kotlin/com/courier/core/ObjectMappers.kt +++ b/courier-java-core/src/main/kotlin/com/courier/core/ObjectMappers.kt @@ -24,7 +24,8 @@ import java.io.InputStream import java.time.DateTimeException import java.time.LocalDate import java.time.LocalDateTime -import java.time.ZonedDateTime +import java.time.OffsetDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.temporal.ChronoField @@ -36,7 +37,7 @@ fun jsonMapper(): JsonMapper = .addModule( SimpleModule() .addSerializer(InputStreamSerializer) - .addDeserializer(LocalDateTime::class.java, LenientLocalDateTimeDeserializer()) + .addDeserializer(OffsetDateTime::class.java, LenientOffsetDateTimeDeserializer()) ) .withCoercionConfig(LogicalType.Boolean) { it.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) @@ -47,6 +48,7 @@ fun jsonMapper(): JsonMapper = } .withCoercionConfig(LogicalType.Integer) { it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) .setCoercion(CoercionInputShape.String, CoercionAction.Fail) .setCoercion(CoercionInputShape.Array, CoercionAction.Fail) .setCoercion(CoercionInputShape.Object, CoercionAction.Fail) @@ -64,6 +66,12 @@ fun jsonMapper(): JsonMapper = .setCoercion(CoercionInputShape.Array, CoercionAction.Fail) .setCoercion(CoercionInputShape.Object, CoercionAction.Fail) } + .withCoercionConfig(LogicalType.DateTime) { + it.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Array, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Object, CoercionAction.Fail) + } .withCoercionConfig(LogicalType.Array) { it.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail) .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) @@ -124,10 +132,10 @@ private object InputStreamSerializer : BaseSerializer(InputStream:: } /** - * A deserializer that can deserialize [LocalDateTime] from datetimes, dates, and zoned datetimes. + * A deserializer that can deserialize [OffsetDateTime] from datetimes, dates, and zoned datetimes. */ -private class LenientLocalDateTimeDeserializer : - StdDeserializer(LocalDateTime::class.java) { +private class LenientOffsetDateTimeDeserializer : + StdDeserializer(OffsetDateTime::class.java) { companion object { @@ -141,7 +149,7 @@ private class LenientLocalDateTimeDeserializer : override fun logicalType(): LogicalType = LogicalType.DateTime - override fun deserialize(p: JsonParser, context: DeserializationContext?): LocalDateTime { + override fun deserialize(p: JsonParser, context: DeserializationContext): OffsetDateTime { val exceptions = mutableListOf() for (formatter in DATE_TIME_FORMATTERS) { @@ -150,17 +158,20 @@ private class LenientLocalDateTimeDeserializer : return when { !temporal.isSupported(ChronoField.HOUR_OF_DAY) -> - LocalDate.from(temporal).atStartOfDay() + LocalDate.from(temporal) + .atStartOfDay() + .atZone(ZoneId.of("UTC")) + .toOffsetDateTime() !temporal.isSupported(ChronoField.OFFSET_SECONDS) -> - LocalDateTime.from(temporal) - else -> ZonedDateTime.from(temporal).toLocalDateTime() + LocalDateTime.from(temporal).atZone(ZoneId.of("UTC")).toOffsetDateTime() + else -> OffsetDateTime.from(temporal) } } catch (e: DateTimeException) { exceptions.add(e) } } - throw JsonParseException(p, "Cannot parse `LocalDateTime` from value: ${p.text}").apply { + throw JsonParseException(p, "Cannot parse `OffsetDateTime` from value: ${p.text}").apply { exceptions.forEach { addSuppressed(it) } } } 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 f2271eb8..91211328 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 @@ -31,10 +31,6 @@ private constructor( ) : HttpClient { override fun execute(request: HttpRequest, requestOptions: RequestOptions): HttpResponse { - if (!isRetryable(request) || maxRetries <= 0) { - return httpClient.execute(request, requestOptions) - } - var modifiedRequest = maybeAddIdempotencyHeader(request) // Don't send the current retry count in the headers if the caller set their own value. @@ -48,6 +44,10 @@ private constructor( modifiedRequest = setRetryCountHeader(modifiedRequest, retries) } + if (!isRetryable(modifiedRequest)) { + return httpClient.execute(modifiedRequest, requestOptions) + } + val response = try { val response = httpClient.execute(modifiedRequest, requestOptions) @@ -75,10 +75,6 @@ private constructor( request: HttpRequest, requestOptions: RequestOptions, ): CompletableFuture { - if (!isRetryable(request) || maxRetries <= 0) { - return httpClient.executeAsync(request, requestOptions) - } - val modifiedRequest = maybeAddIdempotencyHeader(request) // Don't send the current retry count in the headers if the caller set their own value. @@ -94,8 +90,12 @@ private constructor( val requestWithRetryCount = if (shouldSendRetryCount) setRetryCountHeader(request, retries) else request - return httpClient - .executeAsync(requestWithRetryCount, requestOptions) + val responseFuture = httpClient.executeAsync(requestWithRetryCount, requestOptions) + if (!isRetryable(requestWithRetryCount)) { + return responseFuture + } + + return responseFuture .handleAsync( fun( response: HttpResponse?, diff --git a/courier-java-core/src/main/kotlin/com/courier/models/MessageRoutingChannel.kt b/courier-java-core/src/main/kotlin/com/courier/models/MessageRoutingChannel.kt index d578d83f..1a6a9359 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/MessageRoutingChannel.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/MessageRoutingChannel.kt @@ -167,7 +167,7 @@ private constructor( .toList() return when (bestMatches.size) { // This can happen if what we're deserializing is completely incompatible with all - // the possible variants (e.g. deserializing from array). + // the possible variants (e.g. deserializing from boolean). 0 -> MessageRoutingChannel(_json = json) 1 -> bestMatches.single() // If there's more than one match with the highest validity, then use the first diff --git a/courier-java-core/src/main/kotlin/com/courier/models/UserProfileFirebaseToken.kt b/courier-java-core/src/main/kotlin/com/courier/models/UserProfileFirebaseToken.kt index 3f825320..0beef446 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/UserProfileFirebaseToken.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/UserProfileFirebaseToken.kt @@ -165,7 +165,7 @@ private constructor( .toList() return when (bestMatches.size) { // This can happen if what we're deserializing is completely incompatible with all - // the possible variants (e.g. deserializing from object). + // the possible variants (e.g. deserializing from boolean). 0 -> UserProfileFirebaseToken(_json = json) 1 -> bestMatches.single() // If there's more than one match with the highest validity, then use the first diff --git a/courier-java-core/src/main/kotlin/com/courier/models/automations/AutomationTemplate.kt b/courier-java-core/src/main/kotlin/com/courier/models/automations/AutomationTemplate.kt index 2abc185c..940ff12c 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/automations/AutomationTemplate.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/automations/AutomationTemplate.kt @@ -60,7 +60,7 @@ private constructor( fun name(): String = name.getRequired("name") /** - * The version of the template published, draft. + * The version of the template published or drafted. * * @throws CourierInvalidDataException if the JSON field has an unexpected type or is * unexpectedly missing or null (e.g. if the server responded with an unexpected value). @@ -191,7 +191,7 @@ private constructor( */ fun name(name: JsonField) = apply { this.name = name } - /** The version of the template published, draft. */ + /** The version of the template published or drafted. */ fun version(version: Version) = version(JsonField.of(version)) /** @@ -306,7 +306,7 @@ private constructor( (if (createdAt.asKnown().isPresent) 1 else 0) + (if (updatedAt.asKnown().isPresent) 1 else 0) - /** The version of the template published, draft. */ + /** The version of the template published or drafted. */ class Version @JsonCreator private constructor(private val value: JsonField) : Enum { /** diff --git a/courier-java-core/src/main/kotlin/com/courier/models/bulk/BulkListUsersResponse.kt b/courier-java-core/src/main/kotlin/com/courier/models/bulk/BulkListUsersResponse.kt index d326f46d..287867e1 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/bulk/BulkListUsersResponse.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/bulk/BulkListUsersResponse.kt @@ -247,7 +247,14 @@ private constructor( .to(to) .build() - /** User-specific data that will be merged with message.data */ + /** + * User-specific data that will be merged with message.data + * + * This arbitrary value can be deserialized into a custom type using the `convert` method: + * ```java + * MyClass myObject = item.data().convert(MyClass.class); + * ``` + */ @JsonProperty("data") @ExcludeMissing fun _data(): JsonValue = data /** diff --git a/courier-java-core/src/main/kotlin/com/courier/models/bulk/InboundBulkMessageUser.kt b/courier-java-core/src/main/kotlin/com/courier/models/bulk/InboundBulkMessageUser.kt index 4c058b4c..be725b34 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/bulk/InboundBulkMessageUser.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/bulk/InboundBulkMessageUser.kt @@ -41,7 +41,14 @@ private constructor( @JsonProperty("to") @ExcludeMissing to: JsonField = JsonMissing.of(), ) : this(data, preferences, profile, recipient, to, mutableMapOf()) - /** User-specific data that will be merged with message.data */ + /** + * User-specific data that will be merged with message.data + * + * This arbitrary value can be deserialized into a custom type using the `convert` method: + * ```java + * MyClass myObject = inboundBulkMessageUser.data().convert(MyClass.class); + * ``` + */ @JsonProperty("data") @ExcludeMissing fun _data(): JsonValue = data /** diff --git a/courier-java-core/src/main/kotlin/com/courier/models/notifications/NotificationGetContent.kt b/courier-java-core/src/main/kotlin/com/courier/models/notifications/NotificationGetContent.kt index d58e0f1e..d4365985 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/notifications/NotificationGetContent.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/notifications/NotificationGetContent.kt @@ -940,7 +940,7 @@ private constructor( .toList() return when (bestMatches.size) { // This can happen if what we're deserializing is completely incompatible - // with all the possible variants (e.g. deserializing from array). + // with all the possible variants (e.g. deserializing from boolean). 0 -> Content(_json = json) 1 -> bestMatches.single() // If there's more than one match with the highest validity, then use the diff --git a/courier-java-core/src/main/kotlin/com/courier/models/send/SendMessageParams.kt b/courier-java-core/src/main/kotlin/com/courier/models/send/SendMessageParams.kt index 23beed96..80bfc668 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/send/SendMessageParams.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/send/SendMessageParams.kt @@ -2089,7 +2089,7 @@ private constructor( return when (bestMatches.size) { // This can happen if what we're deserializing is completely // incompatible with all the possible variants (e.g. deserializing from - // object). + // boolean). 0 -> ExpiresIn(_json = json) 1 -> bestMatches.single() // If there's more than one match with the highest validity, then use diff --git a/courier-java-core/src/main/kotlin/com/courier/models/users/tokens/TokenRetrieveResponse.kt b/courier-java-core/src/main/kotlin/com/courier/models/users/tokens/TokenRetrieveResponse.kt index ab3e9e48..fdb20a5f 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/users/tokens/TokenRetrieveResponse.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/users/tokens/TokenRetrieveResponse.kt @@ -105,7 +105,14 @@ private constructor( */ fun expiryDate(): Optional = expiryDate.getOptional("expiry_date") - /** Properties about the token. */ + /** + * Properties about the token. + * + * This arbitrary value can be deserialized into a custom type using the `convert` method: + * ```java + * MyClass myObject = tokenRetrieveResponse.properties().convert(MyClass.class); + * ``` + */ @JsonProperty("properties") @ExcludeMissing fun _properties(): JsonValue = properties /** diff --git a/courier-java-core/src/main/kotlin/com/courier/models/users/tokens/UserToken.kt b/courier-java-core/src/main/kotlin/com/courier/models/users/tokens/UserToken.kt index 038730e6..97262acf 100644 --- a/courier-java-core/src/main/kotlin/com/courier/models/users/tokens/UserToken.kt +++ b/courier-java-core/src/main/kotlin/com/courier/models/users/tokens/UserToken.kt @@ -86,7 +86,14 @@ private constructor( */ fun expiryDate(): Optional = expiryDate.getOptional("expiry_date") - /** Properties about the token. */ + /** + * Properties about the token. + * + * This arbitrary value can be deserialized into a custom type using the `convert` method: + * ```java + * MyClass myObject = userToken.properties().convert(MyClass.class); + * ``` + */ @JsonProperty("properties") @ExcludeMissing fun _properties(): JsonValue = properties /** @@ -983,7 +990,7 @@ private constructor( .toList() return when (bestMatches.size) { // This can happen if what we're deserializing is completely incompatible with - // all the possible variants (e.g. deserializing from object). + // all the possible variants (e.g. deserializing from integer). 0 -> ExpiryDate(_json = json) 1 -> bestMatches.single() // If there's more than one match with the highest validity, then use the first diff --git a/courier-java-core/src/test/kotlin/com/courier/core/ObjectMappersTest.kt b/courier-java-core/src/test/kotlin/com/courier/core/ObjectMappersTest.kt index 027efee0..48000ed9 100644 --- a/courier-java-core/src/test/kotlin/com/courier/core/ObjectMappersTest.kt +++ b/courier-java-core/src/test/kotlin/com/courier/core/ObjectMappersTest.kt @@ -3,12 +3,14 @@ package com.courier.core import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.exc.MismatchedInputException import com.fasterxml.jackson.module.kotlin.readValue -import java.time.LocalDateTime +import java.time.LocalDate +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.ZoneOffset import kotlin.reflect.KClass import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.catchThrowable import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource import org.junitpioneer.jupiter.cartesian.CartesianTest @@ -46,11 +48,7 @@ internal class ObjectMappersTest { val VALID_CONVERSIONS = listOf( FLOAT to DOUBLE, - FLOAT to INTEGER, - FLOAT to LONG, DOUBLE to FLOAT, - DOUBLE to INTEGER, - DOUBLE to LONG, INTEGER to FLOAT, INTEGER to DOUBLE, INTEGER to LONG, @@ -58,14 +56,6 @@ internal class ObjectMappersTest { LONG to DOUBLE, LONG to INTEGER, CLASS to MAP, - // These aren't actually valid, but coercion configs don't work for String until - // v2.14.0: https://github.com/FasterXML/jackson-databind/issues/3240 - // We currently test on v2.13.4. - BOOLEAN to STRING, - FLOAT to STRING, - DOUBLE to STRING, - INTEGER to STRING, - LONG to STRING, ) } } @@ -84,19 +74,44 @@ internal class ObjectMappersTest { } } - enum class LenientLocalDateTimeTestCase(val string: String) { - DATE("1998-04-21"), - DATE_TIME("1998-04-21T04:00:00"), - ZONED_DATE_TIME_1("1998-04-21T04:00:00+03:00"), - ZONED_DATE_TIME_2("1998-04-21T04:00:00Z"), + enum class LenientOffsetDateTimeTestCase( + val string: String, + val expectedOffsetDateTime: OffsetDateTime, + ) { + DATE( + "1998-04-21", + expectedOffsetDateTime = + OffsetDateTime.of(LocalDate.of(1998, 4, 21), LocalTime.of(0, 0), ZoneOffset.UTC), + ), + DATE_TIME( + "1998-04-21T04:00:00", + expectedOffsetDateTime = + OffsetDateTime.of(LocalDate.of(1998, 4, 21), LocalTime.of(4, 0), ZoneOffset.UTC), + ), + ZONED_DATE_TIME_1( + "1998-04-21T04:00:00+03:00", + expectedOffsetDateTime = + OffsetDateTime.of( + LocalDate.of(1998, 4, 21), + LocalTime.of(4, 0), + ZoneOffset.ofHours(3), + ), + ), + ZONED_DATE_TIME_2( + "1998-04-21T04:00:00Z", + expectedOffsetDateTime = + OffsetDateTime.of(LocalDate.of(1998, 4, 21), LocalTime.of(4, 0), ZoneOffset.UTC), + ), } @ParameterizedTest @EnumSource - fun readLocalDateTime_lenient(testCase: LenientLocalDateTimeTestCase) { + fun readOffsetDateTime_lenient(testCase: LenientOffsetDateTimeTestCase) { val jsonMapper = jsonMapper() val json = jsonMapper.writeValueAsString(testCase.string) - assertDoesNotThrow { jsonMapper().readValue(json) } + val offsetDateTime = jsonMapper().readValue(json) + + assertThat(offsetDateTime).isEqualTo(testCase.expectedOffsetDateTime) } } diff --git a/courier-java-core/src/test/kotlin/com/courier/models/MessageRoutingChannelTest.kt b/courier-java-core/src/test/kotlin/com/courier/models/MessageRoutingChannelTest.kt index fcfc0fec..767ec93d 100644 --- a/courier-java-core/src/test/kotlin/com/courier/models/MessageRoutingChannelTest.kt +++ b/courier-java-core/src/test/kotlin/com/courier/models/MessageRoutingChannelTest.kt @@ -9,6 +9,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonTypeRef import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource internal class MessageRoutingChannelTest { @@ -67,11 +69,18 @@ internal class MessageRoutingChannelTest { assertThat(roundtrippedMessageRoutingChannel).isEqualTo(messageRoutingChannel) } - @Test - fun incompatibleJsonShapeDeserializesToUnknown() { - val value = JsonValue.from(listOf("invalid", "array")) + enum class IncompatibleJsonShapeTestCase(val value: JsonValue) { + BOOLEAN(JsonValue.from(false)), + INTEGER(JsonValue.from(-1)), + FLOAT(JsonValue.from(3.14)), + ARRAY(JsonValue.from(listOf("invalid", "array"))), + } + + @ParameterizedTest + @EnumSource + fun incompatibleJsonShapeDeserializesToUnknown(testCase: IncompatibleJsonShapeTestCase) { val messageRoutingChannel = - jsonMapper().convertValue(value, jacksonTypeRef()) + jsonMapper().convertValue(testCase.value, jacksonTypeRef()) val e = assertThrows { messageRoutingChannel.validate() } assertThat(e).hasMessageStartingWith("Unknown ") diff --git a/courier-java-core/src/test/kotlin/com/courier/models/UserProfileFirebaseTokenTest.kt b/courier-java-core/src/test/kotlin/com/courier/models/UserProfileFirebaseTokenTest.kt index 99660664..61b8e987 100644 --- a/courier-java-core/src/test/kotlin/com/courier/models/UserProfileFirebaseTokenTest.kt +++ b/courier-java-core/src/test/kotlin/com/courier/models/UserProfileFirebaseTokenTest.kt @@ -9,6 +9,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonTypeRef import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource internal class UserProfileFirebaseTokenTest { @@ -60,11 +62,18 @@ internal class UserProfileFirebaseTokenTest { assertThat(roundtrippedUserProfileFirebaseToken).isEqualTo(userProfileFirebaseToken) } - @Test - fun incompatibleJsonShapeDeserializesToUnknown() { - val value = JsonValue.from(mapOf("invalid" to "object")) + enum class IncompatibleJsonShapeTestCase(val value: JsonValue) { + BOOLEAN(JsonValue.from(false)), + INTEGER(JsonValue.from(-1)), + FLOAT(JsonValue.from(3.14)), + OBJECT(JsonValue.from(mapOf("invalid" to "object"))), + } + + @ParameterizedTest + @EnumSource + fun incompatibleJsonShapeDeserializesToUnknown(testCase: IncompatibleJsonShapeTestCase) { val userProfileFirebaseToken = - jsonMapper().convertValue(value, jacksonTypeRef()) + jsonMapper().convertValue(testCase.value, jacksonTypeRef()) val e = assertThrows { userProfileFirebaseToken.validate() } assertThat(e).hasMessageStartingWith("Unknown ") diff --git a/courier-java-proguard-test/build.gradle.kts b/courier-java-proguard-test/build.gradle.kts index 9d263108..0d3c8c12 100644 --- a/courier-java-proguard-test/build.gradle.kts +++ b/courier-java-proguard-test/build.gradle.kts @@ -19,7 +19,7 @@ dependencies { testImplementation(kotlin("test")) testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3") testImplementation("org.assertj:assertj-core:3.25.3") - testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.4") + testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.0") } tasks.shadowJar { diff --git a/scripts/upload-artifacts b/scripts/upload-artifacts index 729e6f22..10f3c705 100755 --- a/scripts/upload-artifacts +++ b/scripts/upload-artifacts @@ -7,6 +7,8 @@ GREEN='\033[32m' RED='\033[31m' NC='\033[0m' # No Color +MAVEN_REPO_PATH="./build/local-maven-repo" + log_error() { local msg="$1" local headers="$2" @@ -24,7 +26,7 @@ upload_file() { if [ -f "$file_name" ]; then echo -e "${GREEN}Processing file: $file_name${NC}" - pkg_file_name="mvn${file_name#./build/local-maven-repo}" + pkg_file_name="mvn${file_name#"${MAVEN_REPO_PATH}"}" # Get signed URL for uploading artifact file signed_url_response=$(curl -X POST -G "$URL" \ @@ -47,18 +49,20 @@ upload_file() { md5|sha1|sha256|sha512) content_type="text/plain" ;; module) content_type="application/json" ;; pom|xml) content_type="application/xml" ;; + html) content_type="text/html" ;; *) content_type="application/octet-stream" ;; esac # Upload file upload_response=$(curl -v -X PUT \ --retry 5 \ + --retry-all-errors \ -D "$tmp_headers" \ -H "Content-Type: $content_type" \ --data-binary "@${file_name}" "$signed_url" 2>&1) if ! echo "$upload_response" | grep -q "HTTP/[0-9.]* 200"; then - log_error "Failed upload artifact file" "$tmp_headers" "$upload_response" + log_error "Failed to upload artifact file" "$tmp_headers" "$upload_response" fi # Insert small throttle to reduce rate limiting risk @@ -81,6 +85,99 @@ walk_tree() { done } +generate_instructions() { + cat << EOF > "$MAVEN_REPO_PATH/index.html" + + + + Maven Repo + + +

Stainless SDK Maven Repository

+

This is the Maven repository for your Stainless Java SDK build.

+ +

Project configuration

+ +

The details depend on whether you're using Maven or Gradle as your build tool.

+ +

Maven

+ +

Add the following to your project's pom.xml:

+
<repositories>
+    <repository>
+        <id>stainless-sdk-repo</id>
+        <url>https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn</url>
+    </repository>
+</repositories>
+ +

Gradle

+

Add the following to your build.gradle file:

+
repositories {
+    maven {
+        url "https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn"
+    }
+}
+ +
+

Configuring authentication (if required)

+ +

Some accounts may require authentication to access the repository. If so, use the + following instructions, replacing YOUR_STAINLESS_API_TOKEN with your actual token.

+ +

Maven with authentication

+ +

First, ensure you have the following in your Maven settings.xml for repo authentication:

+
<servers>
+    <server>
+        <id>stainless-sdk-repo</id>
+        <configuration>
+            <httpHeaders>
+                <property>
+                    <name>Authorization</name>
+                    <value>Bearer YOUR_STAINLESS_API_TOKEN</value>
+                </property>
+            </httpHeaders>
+        </configuration>
+    </server>
+</servers>
+ +

Then, add the following to your project's pom.xml:

+
<repositories>
+    <repository>
+        <id>stainless-sdk-repo</id>
+        <url>https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn</url>
+    </repository>
+</repositories>
+ +

Gradle with authentication

+

Add the following to your build.gradle file:

+
repositories {
+    maven {
+        url "https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn"
+        credentials(HttpHeaderCredentials) {
+            name = "Authorization"
+            value = "Bearer YOUR_STAINLESS_API_TOKEN"
+        }
+        authentication {
+            header(HttpHeaderAuthentication)
+        }
+    }
+}
+
+ +

Using the repository

+

Once you've configured the repository, you can include dependencies from it as usual. See your + project README + for more details.

+ + +EOF + upload_file "${MAVEN_REPO_PATH}/index.html" + + echo "Configure maven or gradle to use the repo located at 'https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn'" + echo "For more details, see the directions in https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn/index.html" +} + cd "$(dirname "$0")/.." echo "::group::Creating local Maven content" @@ -88,9 +185,9 @@ echo "::group::Creating local Maven content" echo "::endgroup::" echo "::group::Uploading to pkg.stainless.com" -walk_tree "./build/local-maven-repo" +walk_tree "$MAVEN_REPO_PATH" echo "::endgroup::" echo "::group::Generating instructions" -echo "Configure maven or gradle to use the repo located at 'https://pkg.stainless.com/s/${PROJECT}/${SHA}/mvn'" +generate_instructions echo "::endgroup::"