From a68cb2921ade2dde62ed675f0497864d3d8a457b Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 21 Jul 2026 18:49:37 +0200 Subject: [PATCH 01/21] test (MultiPart): A multi part boundary can contains '--' sequence. Remove regex against this sequence and use real boundary string from returned payload" --- CHANGELOG.md | 6 +++- .../core/adapters/apache/HttpClientTest.java | 34 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6696bb88..54ed92d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,12 @@ All notable changes to the **Sinch Java SDK** are documented in this file. > - `[tech]` — technical improvement --- +## v2.2.0 - unreleased -## v2.1.1 patch - 2026-07-21 +### Tests +- **[test]** Fix HttpClient multipart test when returned boundary string contains '--' sequence + +## v2.1.1 - 2026-07-21 ### Build & CI diff --git a/client/src/test/java/com/sinch/sdk/core/adapters/apache/HttpClientTest.java b/client/src/test/java/com/sinch/sdk/core/adapters/apache/HttpClientTest.java index 44952366c..60594fe35 100644 --- a/client/src/test/java/com/sinch/sdk/core/adapters/apache/HttpClientTest.java +++ b/client/src/test/java/com/sinch/sdk/core/adapters/apache/HttpClientTest.java @@ -39,7 +39,8 @@ @TestWithResources class HttpClientTest extends BaseTest { - static final String regExpBoundaryMarker = "--"; + static final String RFC7578_MULTI_PART_BOUNDARY_EOL = "\r\n"; + static final String regExpBoundaryItem = "^.*(\\s)*" + "Content-Disposition: form-data; name=\"%s\"(\\s)*" @@ -161,7 +162,7 @@ void bearerAutoRefresh() throws InterruptedException { null, Collections.singletonList(oAuthManager.getSchema()))); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } // should have requested a token (because of not yet retrieved when service is started) RecordedRequest recordedRequest = mockBackEnd.takeRequest(); @@ -207,7 +208,7 @@ void httpRequestUrlFromServerConfiguration() throws InterruptedException { Arrays.asList("application/json; charset=utf-8"), null)); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } RecordedRequest recordedRequest = mockBackEnd.takeRequest(); HttpUrl url = recordedRequest.getRequestUrl(); @@ -232,7 +233,7 @@ void httpRequestUrlFromHttpRequest() throws InterruptedException { Arrays.asList("application/json; charset=utf-8"), null)); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } RecordedRequest recordedRequest = mockBackEnd.takeRequest(); HttpUrl url = recordedRequest.getRequestUrl(); @@ -261,7 +262,7 @@ void httpRequestTextBody() throws InterruptedException { Arrays.asList("application/json; charset=utf-8"), null)); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } RecordedRequest recordedRequest = mockBackEnd.takeRequest(); String payloadBody = recordedRequest.getBody().readString(StandardCharsets.UTF_8); @@ -283,14 +284,17 @@ void httpRequestFormParamsText() throws InterruptedException { null, new HttpRequest("foo-path", HttpMethod.POST, null, formParams, null, null, null, null)); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } RecordedRequest recordedRequest = mockBackEnd.takeRequest(); assertTrue(recordedRequest.getHeader("Content-Type").contains("multipart/form-data")); String payloadBody = recordedRequest.getBody().readString(StandardCharsets.UTF_8); - String[] split = payloadBody.split(regExpBoundaryMarker); + + String boundaryMarker = + payloadBody.substring(0, payloadBody.indexOf(RFC7578_MULTI_PART_BOUNDARY_EOL)); + String[] split = payloadBody.split(java.util.regex.Pattern.quote(boundaryMarker)); assertTrue( split[1].matches( String.format(regExpBoundaryItem, "my key", "my value with unicode \uD83D\uDCE7"))); @@ -311,13 +315,15 @@ void httpRequestFormParamsArray() throws InterruptedException { null, new HttpRequest("foo-path", HttpMethod.POST, null, formParams, null, null, null, null)); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } RecordedRequest recordedRequest = mockBackEnd.takeRequest(); assertTrue(recordedRequest.getHeader("Content-Type").contains("multipart/form-data")); String payloadBody = recordedRequest.getBody().readString(StandardCharsets.UTF_8); - String[] split = payloadBody.split(regExpBoundaryMarker); + String boundaryMarker = + payloadBody.substring(0, payloadBody.indexOf(RFC7578_MULTI_PART_BOUNDARY_EOL)); + String[] split = payloadBody.split(java.util.regex.Pattern.quote(boundaryMarker)); assertTrue(split[1].matches(String.format(regExpBoundaryItem, "my key", "my value 1"))); assertTrue(split[2].matches(String.format(regExpBoundaryItem, "my key", "my value 2"))); } @@ -351,13 +357,15 @@ void httpRequestFormParamsFile() throws InterruptedException { null, new HttpRequest("foo-path", HttpMethod.POST, null, formParams, null, null, null, null)); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } RecordedRequest recordedRequest = mockBackEnd.takeRequest(); assertTrue(recordedRequest.getHeader("Content-Type").contains("multipart/form-data")); String payloadBody = recordedRequest.getBody().readString(StandardCharsets.UTF_8); - String[] split = payloadBody.split(regExpBoundaryMarker); + String boundaryMarker = + payloadBody.substring(0, payloadBody.indexOf(RFC7578_MULTI_PART_BOUNDARY_EOL)); + String[] split = payloadBody.split(java.util.regex.Pattern.quote(boundaryMarker)); assertTrue(split[1].contains(content)); } @@ -379,7 +387,7 @@ void httpRequestHeaders() throws InterruptedException { new HttpRequest( "foo-path", HttpMethod.GET, null, (String) null, httpRequest, null, null, null)); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } RecordedRequest recordedRequest = mockBackEnd.takeRequest(); String header = recordedRequest.getHeader(key); @@ -403,7 +411,7 @@ void sdkHeaders() throws InterruptedException { null, new HttpRequest("foo-path", HttpMethod.GET, null, (String) null, null, null, null, null)); } catch (ApiException ae) { - // noop + throw new RuntimeException(ae); } RecordedRequest recordedRequest = mockBackEnd.takeRequest(); String header = recordedRequest.getHeader(key); From b7fb156be714847dfffb06341eb0e48e3fc8dc97 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Wed, 22 Jul 2026 16:38:36 +0200 Subject: [PATCH 02/21] CI: Support matrix compilation [21,25] for examples --- .github/workflows/samples-compilation.yaml | 12 +++++++++--- CHANGELOG.md | 3 +++ examples/sinch-events/pom.xml | 6 ++++-- examples/snippets/pom.xml | 7 +++++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/samples-compilation.yaml b/.github/workflows/samples-compilation.yaml index 3d5c2c25e..1e6a85856 100644 --- a/.github/workflows/samples-compilation.yaml +++ b/.github/workflows/samples-compilation.yaml @@ -4,20 +4,26 @@ on: [push] jobs: build: + name: Examples Java ${{ matrix.java-version }} runs-on: ubuntu-latest + strategy: + matrix: + java-version: ['21', '25'] + maven-args: ['clean install -DskipTests=true -Dspotless.check.skip=true -DskipUTs -DskipITs'] + steps: - uses: actions/checkout@v3 - - name: Set up JDK + - name: Set up JDK ${{ matrix.java-version }} uses: actions/setup-java@v3 with: - java-version: '21' + java-version: ${{ matrix.java-version }} distribution: 'temurin' cache: maven - name: Building run: | - mvn clean install -DskipTests=true -Dspotless.apply.skip=true -DskipUTs -DskipITs + mvn -B ${{ matrix.maven-args }} --file pom.xml cd examples ./compile.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ed92d27..55cf08a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All notable changes to the **Sinch Java SDK** are documented in this file. --- ## v2.2.0 - unreleased +### Build & CI +- **[tech]** Build `examples` across a Java version matrix (`21`, `25`) in GitHub Actions, replacing the single Java 21 build. + ### Tests - **[test]** Fix HttpClient multipart test when returned boundary string contains '--' sequence diff --git a/examples/sinch-events/pom.xml b/examples/sinch-events/pom.xml index edb783586..4ab3c4f39 100644 --- a/examples/sinch-events/pom.xml +++ b/examples/sinch-events/pom.xml @@ -27,6 +27,8 @@ [2.0.0,) 21 + 3.8.0 + 1.35.0 @@ -59,7 +61,7 @@ com.diffplug.spotless spotless-maven-plugin - 3.8.0 + ${spotless.version} @@ -69,7 +71,7 @@ - 1.35.0 + ${googleformat.version} true diff --git a/examples/snippets/pom.xml b/examples/snippets/pom.xml index 3a028f2fc..e11f382c1 100644 --- a/examples/snippets/pom.xml +++ b/examples/snippets/pom.xml @@ -17,6 +17,7 @@ ${env.SDK_VERSION} + @@ -25,6 +26,8 @@ 8 3.13.0 UTF-8 + 3.8.0 + 1.35.0 @@ -43,7 +46,7 @@ com.diffplug.spotless spotless-maven-plugin - 2.40.0 + ${spotless.version} @@ -53,7 +56,7 @@ - 1.22.0 + ${googleformat.version} true From 6c6cb688459bd05d4e8dc24e1adcc0b72ea25d85 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Wed, 22 Jul 2026 17:51:11 +0200 Subject: [PATCH 03/21] CI: restrict dependency review to public repo only --- .github/workflows/dependency-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ba1e89b2e..c8643ba85 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -12,6 +12,7 @@ permissions: pull-requests: write jobs: dependency-review: + if: ${{ github.event.repository.visibility == 'public' }} runs-on: ubuntu-latest steps: - name: 'Checkout Repository' From 1af22401d67d4834f68d9fb7b5e5b6b602c0179e Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier <141755467+JPPortier@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:56:03 +0200 Subject: [PATCH 04/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c8643ba85..544a121f8 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -12,7 +12,7 @@ permissions: pull-requests: write jobs: dependency-review: - if: ${{ github.event.repository.visibility == 'public' }} + if: ${{ github.repository_visibility == 'public' }} runs-on: ubuntu-latest steps: - name: 'Checkout Repository' From 4ec1ca88b05ce3d65c87654a43846a9b7074f095 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Mon, 3 Aug 2026 15:00:35 +0200 Subject: [PATCH 05/21] Sprint 11 OAS synch changes (#35) * Sprint 11 OAS synch changes --- CHANGELOG.md | 17 + .../v1/messages/types/choice/Choice.java | 18 + .../types/choice/ChoiceCallMessageImpl.java | 6 +- .../v1/messages/types/choice/ChoiceImpl.java | 36 +- .../choice/ChoiceLocationMessageImpl.java | 6 +- .../types/choice/ChoiceTextMessageImpl.java | 6 +- .../types/choice/ChoiceURLMessageImpl.java | 6 +- .../v1/sinchevents/NumberSinchEvent.java | 66 ++-- .../v1/sinchevents/NumberSinchEventImpl.java | 0 .../v1/adapters/SinchEventsServiceTest.java | 17 +- .../domains/numbers/v1/SinchEventsSteps.java | 28 +- .../events/internal/AppEventInternalImpl.java | 76 +++- .../v1/events/types/ReadMessageEvent.java | 45 +++ .../v1/events/types/ReadMessageEventImpl.java | 91 +++++ .../v1/messages/types/choice/DisplayMode.java | 37 ++ .../internal/ChoiceMessageOneOfInternal.java | 17 + .../ChoiceMessageOneOfInternalImpl.java | 40 ++- .../templates/api/v2/TemplatesV2Service.java | 34 +- .../sinchevents/ActiveNumberSinchEvent.java | 174 +++++++++ .../ActiveNumberSinchEventImpl.java | 332 ++++++++++++++++++ .../v1/sinchevents/CallbackPayloadBase.java | 94 +++++ .../v1/sinchevents/NumberOrderSinchEvent.java | 137 ++++++++ .../NumberOrderSinchEventImpl.java | 259 ++++++++++++++ .../models/v1/sinchevents/ResourceType.java | 6 +- .../models/v1/events/AppEventDtoTest.java | 22 ++ .../events/types/ReadMessageEventDtoTest.java | 42 +++ .../types/choice/ChoiceMessageDtoTest.java | 40 +++ .../numbers/models/v1/SinchEventsDtoTest.java | 50 ++- .../v1/events/types/ReadMessageEventDto.json | 3 + .../ChoiceMessageWithDisplayModeDto.json | 31 ++ ...nt.json => active-number-sinch-event.json} | 0 .../sinchevents/number-order-sinch-event.json | 9 + 32 files changed, 1664 insertions(+), 81 deletions(-) rename {openapi-contracts => client}/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEvent.java (91%) rename {openapi-contracts => client}/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEventImpl.java (100%) create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEvent.java create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEventImpl.java create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/DisplayMode.java create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ActiveNumberSinchEvent.java create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ActiveNumberSinchEventImpl.java create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/CallbackPayloadBase.java create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberOrderSinchEvent.java create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberOrderSinchEventImpl.java create mode 100644 openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEventDtoTest.java create mode 100644 openapi-contracts/src/test/resources/domains/conversation/v1/events/types/ReadMessageEventDto.json create mode 100644 openapi-contracts/src/test/resources/domains/conversation/v1/messages/types/choice/ChoiceMessageWithDisplayModeDto.json rename openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/{number-sinch-event.json => active-number-sinch-event.json} (100%) create mode 100644 openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/number-order-sinch-event.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cf08a94..b40fbcddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,23 @@ All notable changes to the **Sinch Java SDK** are documented in this file. --- ## v2.2.0 - unreleased +### Conversation +- **[feature]** [Events] Support `ReadMessageEvent` app event (WhatsApp only): use `ReadMessageEvent.READ_MESSAGE_EVENT` +- **[feature]** [Messages] [Choice] Support new `displayMode` field and `DisplayMode` enum +- **[tech]** [Templates V2] Synch with backend not returning body onto `delete`. No effect at SDK interface level + +### Numbers +- Extend `NumberSinchEvent` class. + - **[feature]** Support new `NumberSinchEvent`: `ActiveNumberSinchEvent` and `NumberOrderSinchEvent` + - **[deprecation notice]** `NumberSinchEvent` is now a base class for new use cases from backend: `ActiveNumberSinchEvent` and `NumberOrderSinchEvent`. + - The following fields are deprecated at `NumberSinchEvent` level and will be removed in next major version: + - `getResourceId()` + - `getEventType()` + - `getStatus()` + - `getFailureCode()` + - `getInternalFailureCode()` + - Use their dedicated `ActiveNumberSinchEvent` and `NumberOrderSinchEvent` fields + ### Build & CI - **[tech]** Build `examples` across a Java version matrix (`21`, `25`) in GitHub Actions, replacing the single Java 21 build. diff --git a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/Choice.java b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/Choice.java index ea09c9a31..ac878bb4c 100644 --- a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/Choice.java +++ b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/Choice.java @@ -21,6 +21,14 @@ public interface Choice { */ Object getPostbackData(); + /** + * Controls the display behavior of a choice. + * + * @return displayMode + * @since 2.2 + */ + DisplayMode getDisplayMode(); + static Builder builder() { return new ChoiceImpl.Builder<>(); } @@ -39,6 +47,16 @@ interface Builder { */ Builder setPostbackData(Object postbackData); + /** + * see getter + * + * @param displayMode see getter + * @return Current builder + * @see #getDisplayMode + * @since 2.2 + */ + Builder setDisplayMode(DisplayMode displayMode); + Choice build(); } } diff --git a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceCallMessageImpl.java b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceCallMessageImpl.java index 780180d8e..26f07c84f 100644 --- a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceCallMessageImpl.java +++ b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceCallMessageImpl.java @@ -6,8 +6,10 @@ public class ChoiceCallMessageImpl extends ChoiceImpl implements ChoiceCallMessage { private ChoiceCallMessageImpl( - OptionalValue message, OptionalValue postbackData) { - super(message, postbackData); + OptionalValue message, + OptionalValue postbackData, + OptionalValue displayMode) { + super(message, postbackData, displayMode); } /** diff --git a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceImpl.java b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceImpl.java index a8f29cc99..8d497d145 100644 --- a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceImpl.java +++ b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceImpl.java @@ -36,9 +36,15 @@ public class ChoiceImpl implements Choice { private final OptionalValue postbackData; - public ChoiceImpl(OptionalValue message, OptionalValue postbackData) { + private final OptionalValue displayMode; + + public ChoiceImpl( + OptionalValue message, + OptionalValue postbackData, + OptionalValue displayMode) { this.message = message; this.postbackData = postbackData; + this.displayMode = displayMode; } public T getMessage() { @@ -57,9 +63,24 @@ public OptionalValue postbackData() { return postbackData; } + public DisplayMode getDisplayMode() { + return displayMode.orElse(null); + } + + public OptionalValue displayMode() { + return displayMode; + } + @Override public String toString() { - return "ChoiceImpl{" + "message=" + message + ", postbackData=" + postbackData + '}'; + return "ChoiceImpl{" + + "message=" + + message + + ", postbackData=" + + postbackData + + ", displayMode=" + + displayMode + + '}'; } /** Dedicated Builder */ @@ -69,6 +90,8 @@ static class Builder implements Choice.Builder { OptionalValue postbackData = OptionalValue.empty(); + OptionalValue displayMode = OptionalValue.empty(); + public Builder setMessage(T message) { this.message = OptionalValue.of(message); return this; @@ -79,8 +102,13 @@ public Builder setPostbackData(Object postbackData) { return this; } + public Builder setDisplayMode(DisplayMode displayMode) { + this.displayMode = OptionalValue.of(displayMode); + return this; + } + public ChoiceImpl build() { - return new ChoiceImpl<>(message, postbackData); + return new ChoiceImpl<>(message, postbackData, displayMode); } } @@ -102,6 +130,7 @@ public void serialize(Choice raw, JsonGenerator jgen, SerializerProvider provide ChoiceMessageOneOfInternal.Builder internal = ChoiceMessageOneOfInternal.builder(); value.postbackData().ifPresent(internal::setPostbackData); + value.displayMode().ifPresent(internal::setDisplayMode); value .message() .ifPresent( @@ -191,6 +220,7 @@ public Choice deserialize(JsonParser jp, DeserializationContext ctxt) throws return null; } deserialized.postbackData().ifPresent(builder::setPostbackData); + deserialized.displayMode().ifPresent(builder::setDisplayMode); return builder.build(); } } diff --git a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceLocationMessageImpl.java b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceLocationMessageImpl.java index f9f0c41cc..12f3ec7d7 100644 --- a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceLocationMessageImpl.java +++ b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceLocationMessageImpl.java @@ -7,8 +7,10 @@ public class ChoiceLocationMessageImpl extends ChoiceImpl implements ChoiceLocationMessage { private ChoiceLocationMessageImpl( - OptionalValue message, OptionalValue postbackData) { - super(message, postbackData); + OptionalValue message, + OptionalValue postbackData, + OptionalValue displayMode) { + super(message, postbackData, displayMode); } /** diff --git a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceTextMessageImpl.java b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceTextMessageImpl.java index dec2eb618..146946b59 100644 --- a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceTextMessageImpl.java +++ b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceTextMessageImpl.java @@ -6,8 +6,10 @@ public class ChoiceTextMessageImpl extends ChoiceImpl implements ChoiceTextMessage { private ChoiceTextMessageImpl( - OptionalValue message, OptionalValue postbackData) { - super(message, postbackData); + OptionalValue message, + OptionalValue postbackData, + OptionalValue displayMode) { + super(message, postbackData, displayMode); } /** diff --git a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceURLMessageImpl.java b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceURLMessageImpl.java index e7c79f7b6..f90b6438f 100644 --- a/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceURLMessageImpl.java +++ b/client/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceURLMessageImpl.java @@ -6,8 +6,10 @@ public class ChoiceURLMessageImpl extends ChoiceImpl implements ChoiceURLMessage { private ChoiceURLMessageImpl( - OptionalValue message, OptionalValue postbackData) { - super(message, postbackData); + OptionalValue message, + OptionalValue postbackData, + OptionalValue displayMode) { + super(message, postbackData, displayMode); } /** diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEvent.java b/client/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEvent.java similarity index 91% rename from openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEvent.java rename to client/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEvent.java index f66eb20d4..a32c060e5 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEvent.java +++ b/client/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEvent.java @@ -1,11 +1,7 @@ /* * Numbers | Sinch * - * OpenAPI document version: 1.0.3 - * Contact: Support@sinch.com - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * Do not edit the class manually. + * NOTE: This class is NOT generated: it is maintained by hand. */ package com.sinch.sdk.domains.numbers.models.v1.sinchevents; @@ -17,46 +13,26 @@ import java.util.Arrays; import java.util.stream.Stream; -/** NumberSinchEvent */ +/** + * Base class for Numbers Sinch Events. + * + * @see ActiveNumberSinchEvent + * @see NumberOrderSinchEvent + */ @JsonDeserialize(builder = NumberSinchEventImpl.Builder.class) -public interface NumberSinchEvent { - - /** - * The ID of the event. - * - * @return eventId - */ - String getEventId(); - - /** - * The date and time when the callback was created and added to the callbacks queue. - * - * @return timestamp - */ - Instant getTimestamp(); - - /** - * The ID of the project to which the event belongs. - * - * @return projectId - */ - String getProjectId(); +public interface NumberSinchEvent extends CallbackPayloadBase { /** * The unique identifier of the resource, depending on the resource type. For example, a phone * number, a hosting order ID, or a brand ID. * * @return resourceId + * @deprecated Use {@link ActiveNumberSinchEvent#getResourceId()} or {@link + * NumberOrderSinchEvent#getResourceId()} */ + @Deprecated String getResourceId(); - /** - * Get resourceType - * - * @return resourceType - */ - ResourceType getResourceType(); - /** The type of the event. */ public class EventTypeEnum extends EnumDynamic { /** An event that occurs when a number is linked to a Service Plan ID. */ @@ -126,7 +102,10 @@ public static String valueOf(EventTypeEnum e) { * The type of the event. * * @return eventType + * @deprecated Use {@link ActiveNumberSinchEvent#getEventType()} or {@link + * NumberOrderSinchEvent#getEventType()} */ + @Deprecated EventTypeEnum getEventType(); /** The status of the event or the state transition it represents. */ @@ -179,7 +158,10 @@ public static String valueOf(StatusEnum e) { * The status of the event or the state transition it represents. * * @return status + * @deprecated Use {@link ActiveNumberSinchEvent#getStatus()} or {@link + * NumberOrderSinchEvent#getStatus()} */ + @Deprecated StatusEnum getStatus(); /** @@ -289,8 +271,13 @@ public static String valueOf(FailureCodeEnum e) { * campaign provisioning-related failures, refer to the list for the possible values. * * @return failureCode + * @deprecated Use {@link ActiveNumberSinchEvent#getFailureCode()}: the field is defined for + * ACTIVE_NUMBER events only */ - FailureCodeEnum getFailureCode(); + @Deprecated + default FailureCodeEnum getFailureCode() { + return null; + } /** * If the status is FAILED, certain processes (eg. number to campaign provisioning) will have an @@ -299,8 +286,13 @@ public static String valueOf(FailureCodeEnum e) { * errors documentation. * * @return internalFailureCode + * @deprecated Use {@link ActiveNumberSinchEvent#getInternalFailureCode()}: the field is defined + * for ACTIVE_NUMBER events only */ - String getInternalFailureCode(); + @Deprecated + default String getInternalFailureCode() { + return null; + } /** * Getting builder diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEventImpl.java b/client/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEventImpl.java similarity index 100% rename from openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEventImpl.java rename to client/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberSinchEventImpl.java diff --git a/client/src/test/java/com/sinch/sdk/domains/numbers/api/v1/adapters/SinchEventsServiceTest.java b/client/src/test/java/com/sinch/sdk/domains/numbers/api/v1/adapters/SinchEventsServiceTest.java index 423d354eb..f610c586b 100644 --- a/client/src/test/java/com/sinch/sdk/domains/numbers/api/v1/adapters/SinchEventsServiceTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/numbers/api/v1/adapters/SinchEventsServiceTest.java @@ -23,9 +23,12 @@ @TestWithResources public class SinchEventsServiceTest extends BaseTest { - @GivenTextResource("/domains/numbers/v1/sinchevents/number-sinch-event.json") + @GivenTextResource("/domains/numbers/v1/sinchevents/active-number-sinch-event.json") String incomingNumberEventJSON; + @GivenTextResource("/domains/numbers/v1/sinchevents/number-order-sinch-event.json") + String incomingNumberOrderEventJSON; + SinchEventsService sinchEventsService; @Test @@ -49,11 +52,19 @@ void checkValidateAuthenticatedRequest() { } @Test - void parse() throws ApiException { + void parseActiveNumberEvent() throws ApiException { NumberSinchEvent response = sinchEventsService.parseEvent(incomingNumberEventJSON); - TestHelpers.recursiveEquals(response, SinchEventsDtoTest.numberEvent); + TestHelpers.recursiveEquals(response, SinchEventsDtoTest.activeNumberSinchEvent); + } + + @Test + void parseNumberOrderEvent() throws ApiException { + + NumberSinchEvent response = sinchEventsService.parseEvent(incomingNumberOrderEventJSON); + + TestHelpers.recursiveEquals(response, SinchEventsDtoTest.numberOrderEvent); } @BeforeEach diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/numbers/v1/SinchEventsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/numbers/v1/SinchEventsSteps.java index 8adcd341c..ea13369e0 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/numbers/v1/SinchEventsSteps.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/numbers/v1/SinchEventsSteps.java @@ -36,7 +36,10 @@ public class SinchEventsSteps { WEBHOOKS_PATH + "provisioning_to_voice_platform/succeeded"), new AbstractMap.SimpleEntry<>( "failure_" + EventTypeEnum.PROVISIONING_TO_VOICE_PLATFORM.value(), - WEBHOOKS_PATH + "provisioning_to_voice_platform/failed")) + WEBHOOKS_PATH + "provisioning_to_voice_platform/failed"), + new AbstractMap.SimpleEntry<>( + "completed_" + EventTypeEnum.NUMBER_ORDER_PROCESSING.value(), + WEBHOOKS_PATH + "number_order_processing")) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); Map> receivedEvents = new ConcurrentHashMap<>(); @@ -97,8 +100,27 @@ public void validateResult(String status, String trigger) { .setInternalFailureCode(null) .build(); - NumberSinchEvent expected = - Objects.equals(status, "success") ? expectedSuccess : expectedFailure; + NumberSinchEvent expectedNumberOrderCompleted = + NumberSinchEvent.builder() + .setEventId("01j1wefx7p3wf2r3x6h4dh6hh9") + .setTimestamp(Instant.parse("2024-06-06T14:42:42.846638361Z")) + .setProjectId("12c0ffee-dada-beef-cafe-baadc0de5678") + .setResourceId("01jgkbb8xywmz3hhahd76menqf") + .setResourceType(ResourceType.NUMBER_ORDER) + .setEventType(EventTypeEnum.NUMBER_ORDER_PROCESSING) + .setStatus(StatusEnum.COMPLETED) + .setFailureCode(null) + .setInternalFailureCode(null) + .build(); + + NumberSinchEvent expected; + if (Objects.equals(status, "success")) { + expected = expectedSuccess; + } else if (Objects.equals(status, "completed")) { + expected = expectedNumberOrderCompleted; + } else { + expected = expectedFailure; + } NumberSinchEvent receivedEvent = receivedEvents.get(status + "_" + trigger).event; Assertions.assertEquals(expected, receivedEvent); diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/internal/AppEventInternalImpl.java b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/internal/AppEventInternalImpl.java index a636a4a10..c552c92c0 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/internal/AppEventInternalImpl.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/internal/AppEventInternalImpl.java @@ -21,6 +21,7 @@ import com.sinch.sdk.domains.conversation.models.v1.events.types.ComposingEndEventImpl; import com.sinch.sdk.domains.conversation.models.v1.events.types.ComposingEventImpl; import com.sinch.sdk.domains.conversation.models.v1.events.types.GenericEventImpl; +import com.sinch.sdk.domains.conversation.models.v1.events.types.ReadMessageEventImpl; import java.io.IOException; import java.util.Collections; import java.util.HashMap; @@ -318,6 +319,47 @@ public AppEventInternalImpl deserialize(JsonParser jp, DeserializationContext ct log.log(Level.FINER, "Input data does not match schema 'GenericEventImpl'", e); } + // deserialize ReadMessageEventImpl + try { + boolean attemptParsing = true; + // ensure that we respect type coercion as set on the client ObjectMapper + if (ReadMessageEventImpl.class.equals(Integer.class) + || ReadMessageEventImpl.class.equals(Long.class) + || ReadMessageEventImpl.class.equals(Float.class) + || ReadMessageEventImpl.class.equals(Double.class) + || ReadMessageEventImpl.class.equals(Boolean.class) + || ReadMessageEventImpl.class.equals(String.class)) { + attemptParsing = typeCoercion; + if (!attemptParsing) { + attemptParsing |= + ((ReadMessageEventImpl.class.equals(Integer.class) + || ReadMessageEventImpl.class.equals(Long.class)) + && token == JsonToken.VALUE_NUMBER_INT); + attemptParsing |= + ((ReadMessageEventImpl.class.equals(Float.class) + || ReadMessageEventImpl.class.equals(Double.class)) + && token == JsonToken.VALUE_NUMBER_FLOAT); + attemptParsing |= + (ReadMessageEventImpl.class.equals(Boolean.class) + && (token == JsonToken.VALUE_FALSE || token == JsonToken.VALUE_TRUE)); + attemptParsing |= + (ReadMessageEventImpl.class.equals(String.class) + && token == JsonToken.VALUE_STRING); + } + } + if (attemptParsing) { + deserialized = tree.traverse(jp.getCodec()).readValueAs(ReadMessageEventImpl.class); + // TODO: there is no validation against JSON schema constraints + // (min, max, enum, pattern...), this does not perform a strict JSON + // validation, which means the 'match' count may be higher than it should be. + match++; + log.log(Level.FINER, "Input data matches schema 'ReadMessageEventImpl'"); + } + } catch (Exception e) { + // deserialization failed, continue + log.log(Level.FINER, "Input data does not match schema 'ReadMessageEventImpl'", e); + } + if (match == 1) { AppEventInternalImpl ret = new AppEventInternalImpl(); ret.setActualInstance(deserialized); @@ -375,6 +417,11 @@ public AppEventInternalImpl(GenericEventImpl o) { setActualInstance(o); } + public AppEventInternalImpl(ReadMessageEventImpl o) { + super("oneOf", Boolean.FALSE); + setActualInstance(o); + } + static { schemas.put("AgentJoinedEventImpl", AgentJoinedEventImpl.class); schemas.put("AgentLeftEventImpl", AgentLeftEventImpl.class); @@ -382,6 +429,7 @@ public AppEventInternalImpl(GenericEventImpl o) { schemas.put("ComposingEndEventImpl", ComposingEndEventImpl.class); schemas.put("ComposingEventImpl", ComposingEventImpl.class); schemas.put("GenericEventImpl", GenericEventImpl.class); + schemas.put("ReadMessageEventImpl", ReadMessageEventImpl.class); JSONNavigator.registerDescendants( AppEventInternalImpl.class, Collections.unmodifiableMap(schemas)); } @@ -394,7 +442,8 @@ public Map> getSchemas() { /** * Set the instance that matches the oneOf child schema, check the instance parameter is valid * against the oneOf child schemas: AgentJoinedEventImpl, AgentLeftEventImpl, - * CommentReplyEventImpl, ComposingEndEventImpl, ComposingEventImpl, GenericEventImpl + * CommentReplyEventImpl, ComposingEndEventImpl, ComposingEventImpl, GenericEventImpl, + * ReadMessageEventImpl * *

It could be an instance of the 'oneOf' schemas. The oneOf child schemas may themselves be a * composed schema (allOf, anyOf, oneOf). @@ -433,18 +482,24 @@ public void setActualInstance(Object instance) { return; } + if (JSONNavigator.isInstanceOf(ReadMessageEventImpl.class, instance, new HashSet>())) { + super.setActualInstance(instance); + return; + } + throw new RuntimeException( "Invalid instance type. Must be AgentJoinedEventImpl, AgentLeftEventImpl," - + " CommentReplyEventImpl, ComposingEndEventImpl, ComposingEventImpl," - + " GenericEventImpl"); + + " CommentReplyEventImpl, ComposingEndEventImpl, ComposingEventImpl, GenericEventImpl," + + " ReadMessageEventImpl"); } /** * Get the actual instance, which can be the following: AgentJoinedEventImpl, AgentLeftEventImpl, - * CommentReplyEventImpl, ComposingEndEventImpl, ComposingEventImpl, GenericEventImpl + * CommentReplyEventImpl, ComposingEndEventImpl, ComposingEventImpl, GenericEventImpl, + * ReadMessageEventImpl * * @return The actual instance (AgentJoinedEventImpl, AgentLeftEventImpl, CommentReplyEventImpl, - * ComposingEndEventImpl, ComposingEventImpl, GenericEventImpl) + * ComposingEndEventImpl, ComposingEventImpl, GenericEventImpl, ReadMessageEventImpl) */ @Override public Object getActualInstance() { @@ -516,4 +571,15 @@ public ComposingEventImpl getComposingEventImpl() throws ClassCastException { public GenericEventImpl getGenericEventImpl() throws ClassCastException { return (GenericEventImpl) super.getActualInstance(); } + + /** + * Get the actual instance of `ReadMessageEventImpl`. If the actual instance is not + * `ReadMessageEventImpl`, the ClassCastException will be thrown. + * + * @return The actual instance of `ReadMessageEventImpl` + * @throws ClassCastException if the instance is not `ReadMessageEventImpl` + */ + public ReadMessageEventImpl getReadMessageEventImpl() throws ClassCastException { + return (ReadMessageEventImpl) super.getActualInstance(); + } } diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEvent.java b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEvent.java new file mode 100644 index 000000000..1aeefd867 --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEvent.java @@ -0,0 +1,45 @@ +/* + * Conversation API | Sinch + * + * OpenAPI document version: 1.0 + * Contact: support@sinch.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + */ + +package com.sinch.sdk.domains.conversation.models.v1.events.types; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.sinch.sdk.core.models.OptionalValue; +import java.util.Collections; + +/** Read Message Event Type */ +@JsonDeserialize(builder = ReadMessageEventImpl.Builder.class) +public interface ReadMessageEvent + extends com.sinch.sdk.domains.conversation.models.v1.events.AppEvent { + + /** Default EMPTY message to be used to send a ReadMessageEvent */ + ReadMessageEvent READ_MESSAGE_EVENT = + new ReadMessageEventImpl(OptionalValue.of(Collections.emptyMap())); + + /** + * Getting builder + * + * @return New Builder instance + */ + static Builder builder() { + return new ReadMessageEventImpl.Builder(); + } + + /** Dedicated Builder */ + interface Builder { + + /** + * Create instance + * + * @return The instance build with current builder values + */ + ReadMessageEvent build(); + } +} diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEventImpl.java b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEventImpl.java new file mode 100644 index 000000000..8a3ad63b8 --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEventImpl.java @@ -0,0 +1,91 @@ +package com.sinch.sdk.domains.conversation.models.v1.events.types; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.sinch.sdk.core.models.OptionalValue; +import java.util.Objects; + +@JsonPropertyOrder({ReadMessageEventImpl.JSON_PROPERTY_READ_MESSAGE_EVENT}) +@JsonFilter("uninitializedFilter") +@JsonInclude(value = JsonInclude.Include.CUSTOM) +public class ReadMessageEventImpl + implements ReadMessageEvent, com.sinch.sdk.domains.conversation.models.v1.events.AppEvent { + private static final long serialVersionUID = 1L; + + public static final String JSON_PROPERTY_READ_MESSAGE_EVENT = "read_message_event"; + + private OptionalValue readMessageEvent; + + public ReadMessageEventImpl() {} + + protected ReadMessageEventImpl(OptionalValue readMessageEvent) { + this.readMessageEvent = readMessageEvent; + } + + @JsonIgnore + public Object getReadMessageEvent() { + return readMessageEvent.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_READ_MESSAGE_EVENT) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue readMessageEvent() { + return readMessageEvent; + } + + /** Return true if this ReadMessageEventField object is equal to o. */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ReadMessageEventImpl readMessageEventField = (ReadMessageEventImpl) o; + return Objects.equals(this.readMessageEvent, readMessageEventField.readMessageEvent); + } + + @Override + public int hashCode() { + return Objects.hash(readMessageEvent); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class ReadMessageEventImpl {\n"); + sb.append(" readMessageEvent: ").append(toIndentedString(readMessageEvent)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } + + @JsonPOJOBuilder(withPrefix = "set") + static class Builder implements ReadMessageEvent.Builder { + OptionalValue readMessageEvent = OptionalValue.empty(); + + @JsonProperty(value = JSON_PROPERTY_READ_MESSAGE_EVENT, required = true) + public Builder setReadMessageEvent(Object readMessageEvent) { + this.readMessageEvent = OptionalValue.of(readMessageEvent); + return this; + } + + public ReadMessageEvent build() { + return new ReadMessageEventImpl(readMessageEvent); + } + } +} diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/DisplayMode.java b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/DisplayMode.java new file mode 100644 index 000000000..28cea52e2 --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/DisplayMode.java @@ -0,0 +1,37 @@ +package com.sinch.sdk.domains.conversation.models.v1.messages.types.choice; + +import com.sinch.sdk.core.utils.EnumDynamic; +import com.sinch.sdk.core.utils.EnumSupportDynamic; +import java.util.Arrays; +import java.util.stream.Stream; + +/** Controls the display behavior of a choice. */ +public class DisplayMode extends EnumDynamic { + + /** Default. Transient — choice disappears when new messages arrive. */ + public static final DisplayMode DISPLAY_MODE_UNSPECIFIED = + new DisplayMode("DISPLAY_MODE_UNSPECIFIED"); + + /** Persistent — choice remains visible in the message bubble. */ + public static final DisplayMode PERSISTENT = new DisplayMode("PERSISTENT"); + + private static final EnumSupportDynamic ENUM_SUPPORT = + new EnumSupportDynamic<>( + DisplayMode.class, DisplayMode::new, Arrays.asList(DISPLAY_MODE_UNSPECIFIED, PERSISTENT)); + + private DisplayMode(String value) { + super(value); + } + + public static Stream values() { + return ENUM_SUPPORT.values(); + } + + public static DisplayMode from(String value) { + return ENUM_SUPPORT.from(value); + } + + public static String valueOf(DisplayMode e) { + return ENUM_SUPPORT.valueOf(e); + } +} diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/internal/ChoiceMessageOneOfInternal.java b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/internal/ChoiceMessageOneOfInternal.java index b0c957bad..72a48a178 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/internal/ChoiceMessageOneOfInternal.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/internal/ChoiceMessageOneOfInternal.java @@ -11,6 +11,7 @@ package com.sinch.sdk.domains.conversation.models.v1.messages.types.internal; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.sinch.sdk.domains.conversation.models.v1.messages.types.choice.DisplayMode; /** * A choice is an action the user can take such as buttons for quick replies or other call to @@ -69,6 +70,13 @@ public interface ChoiceMessageOneOfInternal { */ Object getPostbackData(); + /** + * Get displayMode + * + * @return displayMode + */ + DisplayMode getDisplayMode(); + /** * Getting builder * @@ -144,6 +152,15 @@ interface Builder { */ Builder setPostbackData(Object postbackData); + /** + * see getter + * + * @param displayMode see getter + * @return Current builder + * @see #getDisplayMode + */ + Builder setDisplayMode(DisplayMode displayMode); + /** * Create instance * diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/internal/ChoiceMessageOneOfInternalImpl.java b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/internal/ChoiceMessageOneOfInternalImpl.java index 387c710e1..fd68018dd 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/internal/ChoiceMessageOneOfInternalImpl.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/messages/types/internal/ChoiceMessageOneOfInternalImpl.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; import com.sinch.sdk.core.models.OptionalValue; +import com.sinch.sdk.domains.conversation.models.v1.messages.types.choice.DisplayMode; import java.util.Objects; @JsonPropertyOrder({ @@ -16,7 +17,8 @@ ChoiceMessageOneOfInternalImpl.JSON_PROPERTY_URL_MESSAGE, ChoiceMessageOneOfInternalImpl.JSON_PROPERTY_CALENDAR_MESSAGE, ChoiceMessageOneOfInternalImpl.JSON_PROPERTY_SHARE_LOCATION_MESSAGE, - ChoiceMessageOneOfInternalImpl.JSON_PROPERTY_POSTBACK_DATA + ChoiceMessageOneOfInternalImpl.JSON_PROPERTY_POSTBACK_DATA, + ChoiceMessageOneOfInternalImpl.JSON_PROPERTY_DISPLAY_MODE }) @JsonFilter("uninitializedFilter") @JsonInclude(value = JsonInclude.Include.CUSTOM) @@ -51,6 +53,10 @@ public class ChoiceMessageOneOfInternalImpl implements ChoiceMessageOneOfInterna private OptionalValue postbackData; + public static final String JSON_PROPERTY_DISPLAY_MODE = "display_mode"; + + private OptionalValue displayMode; + public ChoiceMessageOneOfInternalImpl() {} protected ChoiceMessageOneOfInternalImpl( @@ -60,7 +66,8 @@ protected ChoiceMessageOneOfInternalImpl( OptionalValue urlMessage, OptionalValue calendarMessage, OptionalValue shareLocationMessage, - OptionalValue postbackData) { + OptionalValue postbackData, + OptionalValue displayMode) { this.callMessage = callMessage; this.locationMessage = locationMessage; this.textMessage = textMessage; @@ -68,6 +75,7 @@ protected ChoiceMessageOneOfInternalImpl( this.calendarMessage = calendarMessage; this.shareLocationMessage = shareLocationMessage; this.postbackData = postbackData; + this.displayMode = displayMode; } @JsonIgnore @@ -147,6 +155,17 @@ public OptionalValue postbackData() { return postbackData; } + @JsonIgnore + public DisplayMode getDisplayMode() { + return displayMode.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_DISPLAY_MODE) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue displayMode() { + return displayMode; + } + /** Return true if this Choice object is equal to o. */ @Override public boolean equals(Object o) { @@ -163,7 +182,8 @@ public boolean equals(Object o) { && Objects.equals(this.urlMessage, choice.urlMessage) && Objects.equals(this.calendarMessage, choice.calendarMessage) && Objects.equals(this.shareLocationMessage, choice.shareLocationMessage) - && Objects.equals(this.postbackData, choice.postbackData); + && Objects.equals(this.postbackData, choice.postbackData) + && Objects.equals(this.displayMode, choice.displayMode); } @Override @@ -175,7 +195,8 @@ public int hashCode() { urlMessage, calendarMessage, shareLocationMessage, - postbackData); + postbackData, + displayMode); } @Override @@ -191,6 +212,7 @@ public String toString() { .append(toIndentedString(shareLocationMessage)) .append("\n"); sb.append(" postbackData: ").append(toIndentedString(postbackData)).append("\n"); + sb.append(" displayMode: ").append(toIndentedString(displayMode)).append("\n"); sb.append("}"); return sb.toString(); } @@ -214,6 +236,7 @@ static class Builder implements ChoiceMessageOneOfInternal.Builder { OptionalValue calendarMessage = OptionalValue.empty(); OptionalValue shareLocationMessage = OptionalValue.empty(); OptionalValue postbackData = OptionalValue.empty(); + OptionalValue displayMode = OptionalValue.empty(); @JsonProperty(JSON_PROPERTY_CALL_MESSAGE) public Builder setCallMessage(CallMessageInternal callMessage) { @@ -257,6 +280,12 @@ public Builder setPostbackData(Object postbackData) { return this; } + @JsonProperty(JSON_PROPERTY_DISPLAY_MODE) + public Builder setDisplayMode(DisplayMode displayMode) { + this.displayMode = OptionalValue.of(displayMode); + return this; + } + public ChoiceMessageOneOfInternal build() { return new ChoiceMessageOneOfInternalImpl( callMessage, @@ -265,7 +294,8 @@ public ChoiceMessageOneOfInternal build() { urlMessage, calendarMessage, shareLocationMessage, - postbackData); + postbackData, + displayMode); } } } diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/templates/api/v2/TemplatesV2Service.java b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/templates/api/v2/TemplatesV2Service.java index 517af4e12..defa04ffb 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/templates/api/v2/TemplatesV2Service.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/templates/api/v2/TemplatesV2Service.java @@ -22,15 +22,19 @@ public interface TemplatesV2Service { /** * List all templates belonging to a project ID. * + *

Lists all templates belonging to the given project ID. + * * @return TemplatesV2ListResponse * @throws ApiException if fails to make API call */ TemplatesV2ListResponse list() throws ApiException; /** - * Creates a template + * Creates a template. + * + *

Creates a new template under the given project ID. * - * @param templateV2 Required. The template to create. (required) + * @param templateV2 The template to create. (required) * @return TemplateV2 * @throws ApiException if fails to make API call */ @@ -39,33 +43,41 @@ public interface TemplatesV2Service { /** * Delete a template. * - * @param templateId Required. The ID of the template to delete. (required) + *

Deletes a template under the given project ID. + * + * @param templateId The ID of the template to delete. (required) * @throws ApiException if fails to make API call */ void delete(String templateId) throws ApiException; /** - * Get a template + * Get a template. + * + *

Fetches a template under the given project ID. * - * @param templateId Required. The ID of the template to fetch. (required) + * @param templateId The ID of the template to fetch. (required) * @return TemplateV2 * @throws ApiException if fails to make API call */ TemplateV2 get(String templateId) throws ApiException; /** - * List translations for a template (using default parameters) + * List translations for a template. (using default parameters) * - * @param templateId Required. The ID of the template to fetch. (required) + *

Lists all translations for a template under the given project ID. + * + * @param templateId The ID of the template to fetch. (required) * @return TranslationsV2ListResponse * @throws ApiException if fails to make API call */ TranslationsV2ListResponse listTranslations(String templateId) throws ApiException; /** - * List translations for a template + * List translations for a template. + * + *

Lists all translations for a template under the given project ID. * - * @param templateId Required. The ID of the template to fetch. (required) + * @param templateId The ID of the template to fetch. (required) * @param queryParameter (optional) * @return TranslationsV2ListResponse * @throws ApiException if fails to make API call @@ -76,9 +88,11 @@ TranslationsV2ListResponse listTranslations( /** * Updates a template. * + *

Updates a template under the given project ID. + * * @param templateId The id of the template to be updated. Specified or automatically generated * during template creation. Unique per project. (required) - * @param templateV2 Required. The updated template. (required) + * @param templateV2 The updated template. (required) * @return TemplateV2 * @throws ApiException if fails to make API call */ diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ActiveNumberSinchEvent.java b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ActiveNumberSinchEvent.java new file mode 100644 index 000000000..2b26f9ecc --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ActiveNumberSinchEvent.java @@ -0,0 +1,174 @@ +/* + * Numbers | Sinch + * + * OpenAPI document version: 1.0.3 + * Contact: Support@sinch.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + */ + +package com.sinch.sdk.domains.numbers.models.v1.sinchevents; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.time.Instant; + +/** ActiveNumberSinchEvent */ +@JsonDeserialize(builder = ActiveNumberSinchEventImpl.Builder.class) +public interface ActiveNumberSinchEvent + extends com.sinch.sdk.domains.numbers.models.v1.sinchevents.NumberSinchEvent { + + /** + * The ID of the event. + * + * @return eventId + */ + String getEventId(); + + /** + * The date and time when the callback was created and added to the callbacks queue. + * + * @return timestamp + */ + Instant getTimestamp(); + + /** + * The ID of the project to which the event belongs. + * + * @return projectId + */ + String getProjectId(); + + /** + * The type of the event. + * + * @return eventType + */ + EventTypeEnum getEventType(); + + /** + * The status of the event or the state transition it represents. + * + * @return status + */ + StatusEnum getStatus(); + + /** + * If the status is FAILED, a failure code will be provided. For numbers provisioning to SMS + * platform, there won't be any extra failureCode, as the result is binary. For + * campaign provisioning-related failures, refer to the list for the possible values. + * + * @return failureCode + */ + FailureCodeEnum getFailureCode(); + + /** + * If the status is FAILED, certain processes (eg. number to campaign provisioning) will have an + * internalFailureCode in the payload. The details of these codes can be found in our dedicated Provisioning + * errors documentation. + * + * @return internalFailureCode + */ + String getInternalFailureCode(); + + /** + * The unique identifier of the resource, depending on the resource type. For example, a phone + * number. + * + * @return resourceId + */ + String getResourceId(); + + /** + * Getting builder + * + * @return New Builder instance + */ + static Builder builder() { + return new ActiveNumberSinchEventImpl.Builder(); + } + + /** Dedicated Builder */ + interface Builder { + + /** + * see getter + * + * @param eventId see getter + * @return Current builder + * @see #getEventId + */ + Builder setEventId(String eventId); + + /** + * see getter + * + * @param timestamp see getter + * @return Current builder + * @see #getTimestamp + */ + Builder setTimestamp(Instant timestamp); + + /** + * see getter + * + * @param projectId see getter + * @return Current builder + * @see #getProjectId + */ + Builder setProjectId(String projectId); + + /** + * see getter + * + * @param eventType see getter + * @return Current builder + * @see #getEventType + */ + Builder setEventType(EventTypeEnum eventType); + + /** + * see getter + * + * @param status see getter + * @return Current builder + * @see #getStatus + */ + Builder setStatus(StatusEnum status); + + /** + * see getter + * + * @param failureCode see getter + * @return Current builder + * @see #getFailureCode + */ + Builder setFailureCode(FailureCodeEnum failureCode); + + /** + * see getter + * + * @param internalFailureCode see getter + * @return Current builder + * @see #getInternalFailureCode + */ + Builder setInternalFailureCode(String internalFailureCode); + + /** + * see getter + * + * @param resourceId see getter + * @return Current builder + * @see #getResourceId + */ + Builder setResourceId(String resourceId); + + /** + * Create instance + * + * @return The instance build with current builder values + */ + ActiveNumberSinchEvent build(); + } +} diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ActiveNumberSinchEventImpl.java b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ActiveNumberSinchEventImpl.java new file mode 100644 index 000000000..0fd5fd395 --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ActiveNumberSinchEventImpl.java @@ -0,0 +1,332 @@ +package com.sinch.sdk.domains.numbers.models.v1.sinchevents; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.sinch.sdk.core.models.OptionalValue; +import java.time.Instant; +import java.util.Objects; + +@JsonPropertyOrder({ + ActiveNumberSinchEventImpl.JSON_PROPERTY_EVENT_ID, + ActiveNumberSinchEventImpl.JSON_PROPERTY_TIMESTAMP, + ActiveNumberSinchEventImpl.JSON_PROPERTY_PROJECT_ID, + ActiveNumberSinchEventImpl.JSON_PROPERTY_RESOURCE_TYPE, + ActiveNumberSinchEventImpl.JSON_PROPERTY_EVENT_TYPE, + ActiveNumberSinchEventImpl.JSON_PROPERTY_STATUS, + ActiveNumberSinchEventImpl.JSON_PROPERTY_FAILURE_CODE, + ActiveNumberSinchEventImpl.JSON_PROPERTY_INTERNAL_FAILURE_CODE, + ActiveNumberSinchEventImpl.JSON_PROPERTY_RESOURCE_ID +}) +@JsonFilter("uninitializedFilter") +@JsonInclude(value = JsonInclude.Include.CUSTOM) +public class ActiveNumberSinchEventImpl + implements ActiveNumberSinchEvent, + com.sinch.sdk.domains.numbers.models.v1.sinchevents.NumberSinchEvent { + private static final long serialVersionUID = 1L; + + public static final String JSON_PROPERTY_EVENT_ID = "eventId"; + + private OptionalValue eventId; + + public static final String JSON_PROPERTY_TIMESTAMP = "timestamp"; + + private OptionalValue timestamp; + + public static final String JSON_PROPERTY_PROJECT_ID = "projectId"; + + private OptionalValue projectId; + + public static final String JSON_PROPERTY_RESOURCE_TYPE = "resourceType"; + + private OptionalValue resourceType; + + public static final String JSON_PROPERTY_EVENT_TYPE = "eventType"; + + private OptionalValue eventType; + + public static final String JSON_PROPERTY_STATUS = "status"; + + private OptionalValue status; + + public static final String JSON_PROPERTY_FAILURE_CODE = "failureCode"; + + private OptionalValue failureCode; + + public static final String JSON_PROPERTY_INTERNAL_FAILURE_CODE = "internalFailureCode"; + + private OptionalValue internalFailureCode; + + public static final String JSON_PROPERTY_RESOURCE_ID = "resourceId"; + + private OptionalValue resourceId; + + public ActiveNumberSinchEventImpl() {} + + protected ActiveNumberSinchEventImpl( + OptionalValue eventId, + OptionalValue timestamp, + OptionalValue projectId, + OptionalValue resourceType, + OptionalValue eventType, + OptionalValue status, + OptionalValue failureCode, + OptionalValue internalFailureCode, + OptionalValue resourceId) { + this.eventId = eventId; + this.timestamp = timestamp; + this.projectId = projectId; + this.resourceType = resourceType; + this.eventType = eventType; + this.status = status; + this.failureCode = failureCode; + this.internalFailureCode = internalFailureCode; + this.resourceId = resourceId; + } + + @JsonIgnore + public String getEventId() { + return eventId.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_EVENT_ID) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue eventId() { + return eventId; + } + + @JsonIgnore + public Instant getTimestamp() { + return timestamp.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_TIMESTAMP) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue timestamp() { + return timestamp; + } + + @JsonIgnore + public String getProjectId() { + return projectId.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_PROJECT_ID) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue projectId() { + return projectId; + } + + @JsonIgnore + public ResourceType getResourceType() { + return resourceType.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_RESOURCE_TYPE) + @JsonInclude(value = JsonInclude.Include.ALWAYS) + public OptionalValue resourceType() { + return resourceType; + } + + @JsonIgnore + public EventTypeEnum getEventType() { + return eventType.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_EVENT_TYPE) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue eventType() { + return eventType; + } + + @JsonIgnore + public StatusEnum getStatus() { + return status.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_STATUS) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue status() { + return status; + } + + @JsonIgnore + public FailureCodeEnum getFailureCode() { + return failureCode.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_FAILURE_CODE) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue failureCode() { + return failureCode; + } + + @JsonIgnore + public String getInternalFailureCode() { + return internalFailureCode.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_INTERNAL_FAILURE_CODE) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue internalFailureCode() { + return internalFailureCode; + } + + @JsonIgnore + public String getResourceId() { + return resourceId.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_RESOURCE_ID) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue resourceId() { + return resourceId; + } + + /** Return true if this CallbackPayloadActiveNumber object is equal to o. */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ActiveNumberSinchEventImpl callbackPayloadActiveNumber = (ActiveNumberSinchEventImpl) o; + return Objects.equals(this.eventId, callbackPayloadActiveNumber.eventId) + && Objects.equals(this.timestamp, callbackPayloadActiveNumber.timestamp) + && Objects.equals(this.projectId, callbackPayloadActiveNumber.projectId) + && Objects.equals(this.resourceType, callbackPayloadActiveNumber.resourceType) + && Objects.equals(this.eventType, callbackPayloadActiveNumber.eventType) + && Objects.equals(this.status, callbackPayloadActiveNumber.status) + && Objects.equals(this.failureCode, callbackPayloadActiveNumber.failureCode) + && Objects.equals(this.internalFailureCode, callbackPayloadActiveNumber.internalFailureCode) + && Objects.equals(this.resourceId, callbackPayloadActiveNumber.resourceId); + } + + @Override + public int hashCode() { + return Objects.hash( + eventId, + timestamp, + projectId, + resourceType, + eventType, + status, + failureCode, + internalFailureCode, + resourceId); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class ActiveNumberSinchEventImpl {\n"); + sb.append(" eventId: ").append(toIndentedString(eventId)).append("\n"); + sb.append(" timestamp: ").append(toIndentedString(timestamp)).append("\n"); + sb.append(" projectId: ").append(toIndentedString(projectId)).append("\n"); + sb.append(" resourceType: ").append(toIndentedString(resourceType)).append("\n"); + sb.append(" eventType: ").append(toIndentedString(eventType)).append("\n"); + sb.append(" status: ").append(toIndentedString(status)).append("\n"); + sb.append(" failureCode: ").append(toIndentedString(failureCode)).append("\n"); + sb.append(" internalFailureCode: ") + .append(toIndentedString(internalFailureCode)) + .append("\n"); + sb.append(" resourceId: ").append(toIndentedString(resourceId)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } + + @JsonPOJOBuilder(withPrefix = "set") + static class Builder implements ActiveNumberSinchEvent.Builder { + OptionalValue eventId = OptionalValue.empty(); + OptionalValue timestamp = OptionalValue.empty(); + OptionalValue projectId = OptionalValue.empty(); + OptionalValue resourceType = OptionalValue.empty(); + OptionalValue eventType = OptionalValue.empty(); + OptionalValue status = OptionalValue.empty(); + OptionalValue failureCode = OptionalValue.empty(); + OptionalValue internalFailureCode = OptionalValue.empty(); + OptionalValue resourceId = OptionalValue.empty(); + + @JsonProperty(JSON_PROPERTY_EVENT_ID) + public Builder setEventId(String eventId) { + this.eventId = OptionalValue.of(eventId); + return this; + } + + @JsonProperty(JSON_PROPERTY_TIMESTAMP) + public Builder setTimestamp(Instant timestamp) { + this.timestamp = OptionalValue.of(timestamp); + return this; + } + + @JsonProperty(JSON_PROPERTY_PROJECT_ID) + public Builder setProjectId(String projectId) { + this.projectId = OptionalValue.of(projectId); + return this; + } + + @JsonProperty(value = JSON_PROPERTY_RESOURCE_TYPE, required = true) + public Builder setResourceType(ResourceType resourceType) { + this.resourceType = OptionalValue.of(resourceType); + return this; + } + + @JsonProperty(JSON_PROPERTY_EVENT_TYPE) + public Builder setEventType(EventTypeEnum eventType) { + this.eventType = OptionalValue.of(eventType); + return this; + } + + @JsonProperty(JSON_PROPERTY_STATUS) + public Builder setStatus(StatusEnum status) { + this.status = OptionalValue.of(status); + return this; + } + + @JsonProperty(JSON_PROPERTY_FAILURE_CODE) + public Builder setFailureCode(FailureCodeEnum failureCode) { + this.failureCode = OptionalValue.of(failureCode); + return this; + } + + @JsonProperty(JSON_PROPERTY_INTERNAL_FAILURE_CODE) + public Builder setInternalFailureCode(String internalFailureCode) { + this.internalFailureCode = OptionalValue.of(internalFailureCode); + return this; + } + + @JsonProperty(JSON_PROPERTY_RESOURCE_ID) + public Builder setResourceId(String resourceId) { + this.resourceId = OptionalValue.of(resourceId); + return this; + } + + public ActiveNumberSinchEvent build() { + return new ActiveNumberSinchEventImpl( + eventId, + timestamp, + projectId, + resourceType, + eventType, + status, + failureCode, + internalFailureCode, + resourceId); + } + } +} diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/CallbackPayloadBase.java b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/CallbackPayloadBase.java new file mode 100644 index 000000000..a6833ff23 --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/CallbackPayloadBase.java @@ -0,0 +1,94 @@ +/* + * Numbers | Sinch + * + * OpenAPI document version: 1.0.3 + * Contact: Support@sinch.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + */ + +package com.sinch.sdk.domains.numbers.models.v1.sinchevents; + +import java.time.Instant; + +/** CallbackPayloadBase */ +public interface CallbackPayloadBase { + + /** + * The ID of the event. + * + * @return eventId + */ + String getEventId(); + + /** + * The date and time when the callback was created and added to the callbacks queue. + * + * @return timestamp + */ + Instant getTimestamp(); + + /** + * The ID of the project to which the event belongs. + * + * @return projectId + */ + String getProjectId(); + + /** + * Get resourceType + * + *

Field is required + * + * @return resourceType + */ + ResourceType getResourceType(); + + /** Dedicated Builder */ + interface Builder { + + /** + * see getter + * + * @param eventId see getter + * @return Current builder + * @see #getEventId + */ + Builder setEventId(String eventId); + + /** + * see getter + * + * @param timestamp see getter + * @return Current builder + * @see #getTimestamp + */ + Builder setTimestamp(Instant timestamp); + + /** + * see getter + * + * @param projectId see getter + * @return Current builder + * @see #getProjectId + */ + Builder setProjectId(String projectId); + + /** + * see getter + * + * @param resourceType see getter + * @return Current builder + * @see #getResourceType + */ + Builder setResourceType(ResourceType resourceType); + + /** + * Create instance + * + * @return The instance build with current builder values + */ + CallbackPayloadBase build(); + } +} diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberOrderSinchEvent.java b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberOrderSinchEvent.java new file mode 100644 index 000000000..d9fa7a2b8 --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberOrderSinchEvent.java @@ -0,0 +1,137 @@ +/* + * Numbers | Sinch + * + * OpenAPI document version: 1.0.3 + * Contact: Support@sinch.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit the class manually. + */ + +package com.sinch.sdk.domains.numbers.models.v1.sinchevents; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.time.Instant; + +/** NumberOrderSinchEvent */ +@JsonDeserialize(builder = NumberOrderSinchEventImpl.Builder.class) +public interface NumberOrderSinchEvent + extends com.sinch.sdk.domains.numbers.models.v1.sinchevents.NumberSinchEvent { + + /** + * The ID of the event. + * + * @return eventId + */ + String getEventId(); + + /** + * The date and time when the callback was created and added to the callbacks queue. + * + * @return timestamp + */ + Instant getTimestamp(); + + /** + * The ID of the project to which the event belongs. + * + * @return projectId + */ + String getProjectId(); + + /** + * The type of the event. + * + * @return eventType + */ + EventTypeEnum getEventType(); + + /** + * The status of the event or the state transition it represents. + * + * @return status + */ + StatusEnum getStatus(); + + /** + * The unique identifier of the resource, depending on the resource type. For example, a number + * order ID. + * + * @return resourceId + */ + String getResourceId(); + + /** + * Getting builder + * + * @return New Builder instance + */ + static Builder builder() { + return new NumberOrderSinchEventImpl.Builder(); + } + + /** Dedicated Builder */ + interface Builder { + + /** + * see getter + * + * @param eventId see getter + * @return Current builder + * @see #getEventId + */ + Builder setEventId(String eventId); + + /** + * see getter + * + * @param timestamp see getter + * @return Current builder + * @see #getTimestamp + */ + Builder setTimestamp(Instant timestamp); + + /** + * see getter + * + * @param projectId see getter + * @return Current builder + * @see #getProjectId + */ + Builder setProjectId(String projectId); + + /** + * see getter + * + * @param eventType see getter + * @return Current builder + * @see #getEventType + */ + Builder setEventType(EventTypeEnum eventType); + + /** + * see getter + * + * @param status see getter + * @return Current builder + * @see #getStatus + */ + Builder setStatus(StatusEnum status); + + /** + * see getter + * + * @param resourceId see getter + * @return Current builder + * @see #getResourceId + */ + Builder setResourceId(String resourceId); + + /** + * Create instance + * + * @return The instance build with current builder values + */ + NumberOrderSinchEvent build(); + } +} diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberOrderSinchEventImpl.java b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberOrderSinchEventImpl.java new file mode 100644 index 000000000..93acf210a --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/NumberOrderSinchEventImpl.java @@ -0,0 +1,259 @@ +package com.sinch.sdk.domains.numbers.models.v1.sinchevents; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.sinch.sdk.core.models.OptionalValue; +import java.time.Instant; +import java.util.Objects; + +@JsonPropertyOrder({ + NumberOrderSinchEventImpl.JSON_PROPERTY_EVENT_ID, + NumberOrderSinchEventImpl.JSON_PROPERTY_TIMESTAMP, + NumberOrderSinchEventImpl.JSON_PROPERTY_PROJECT_ID, + NumberOrderSinchEventImpl.JSON_PROPERTY_RESOURCE_TYPE, + NumberOrderSinchEventImpl.JSON_PROPERTY_EVENT_TYPE, + NumberOrderSinchEventImpl.JSON_PROPERTY_STATUS, + NumberOrderSinchEventImpl.JSON_PROPERTY_RESOURCE_ID +}) +@JsonFilter("uninitializedFilter") +@JsonInclude(value = JsonInclude.Include.CUSTOM) +public class NumberOrderSinchEventImpl + implements NumberOrderSinchEvent, + com.sinch.sdk.domains.numbers.models.v1.sinchevents.NumberSinchEvent { + private static final long serialVersionUID = 1L; + + public static final String JSON_PROPERTY_EVENT_ID = "eventId"; + + private OptionalValue eventId; + + public static final String JSON_PROPERTY_TIMESTAMP = "timestamp"; + + private OptionalValue timestamp; + + public static final String JSON_PROPERTY_PROJECT_ID = "projectId"; + + private OptionalValue projectId; + + public static final String JSON_PROPERTY_RESOURCE_TYPE = "resourceType"; + + private OptionalValue resourceType; + + public static final String JSON_PROPERTY_EVENT_TYPE = "eventType"; + + private OptionalValue eventType; + + public static final String JSON_PROPERTY_STATUS = "status"; + + private OptionalValue status; + + public static final String JSON_PROPERTY_RESOURCE_ID = "resourceId"; + + private OptionalValue resourceId; + + public NumberOrderSinchEventImpl() {} + + protected NumberOrderSinchEventImpl( + OptionalValue eventId, + OptionalValue timestamp, + OptionalValue projectId, + OptionalValue resourceType, + OptionalValue eventType, + OptionalValue status, + OptionalValue resourceId) { + this.eventId = eventId; + this.timestamp = timestamp; + this.projectId = projectId; + this.resourceType = resourceType; + this.eventType = eventType; + this.status = status; + this.resourceId = resourceId; + } + + @JsonIgnore + public String getEventId() { + return eventId.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_EVENT_ID) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue eventId() { + return eventId; + } + + @JsonIgnore + public Instant getTimestamp() { + return timestamp.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_TIMESTAMP) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue timestamp() { + return timestamp; + } + + @JsonIgnore + public String getProjectId() { + return projectId.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_PROJECT_ID) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue projectId() { + return projectId; + } + + @JsonIgnore + public ResourceType getResourceType() { + return resourceType.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_RESOURCE_TYPE) + @JsonInclude(value = JsonInclude.Include.ALWAYS) + public OptionalValue resourceType() { + return resourceType; + } + + @JsonIgnore + public EventTypeEnum getEventType() { + return eventType.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_EVENT_TYPE) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue eventType() { + return eventType; + } + + @JsonIgnore + public StatusEnum getStatus() { + return status.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_STATUS) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue status() { + return status; + } + + @JsonIgnore + public String getResourceId() { + return resourceId.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_RESOURCE_ID) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue resourceId() { + return resourceId; + } + + /** Return true if this CallbackPayloadNumberOrder object is equal to o. */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NumberOrderSinchEventImpl callbackPayloadNumberOrder = (NumberOrderSinchEventImpl) o; + return Objects.equals(this.eventId, callbackPayloadNumberOrder.eventId) + && Objects.equals(this.timestamp, callbackPayloadNumberOrder.timestamp) + && Objects.equals(this.projectId, callbackPayloadNumberOrder.projectId) + && Objects.equals(this.resourceType, callbackPayloadNumberOrder.resourceType) + && Objects.equals(this.eventType, callbackPayloadNumberOrder.eventType) + && Objects.equals(this.status, callbackPayloadNumberOrder.status) + && Objects.equals(this.resourceId, callbackPayloadNumberOrder.resourceId); + } + + @Override + public int hashCode() { + return Objects.hash(eventId, timestamp, projectId, resourceType, eventType, status, resourceId); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class NumberOrderSinchEventImpl {\n"); + sb.append(" eventId: ").append(toIndentedString(eventId)).append("\n"); + sb.append(" timestamp: ").append(toIndentedString(timestamp)).append("\n"); + sb.append(" projectId: ").append(toIndentedString(projectId)).append("\n"); + sb.append(" resourceType: ").append(toIndentedString(resourceType)).append("\n"); + sb.append(" eventType: ").append(toIndentedString(eventType)).append("\n"); + sb.append(" status: ").append(toIndentedString(status)).append("\n"); + sb.append(" resourceId: ").append(toIndentedString(resourceId)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } + + @JsonPOJOBuilder(withPrefix = "set") + static class Builder implements NumberOrderSinchEvent.Builder { + OptionalValue eventId = OptionalValue.empty(); + OptionalValue timestamp = OptionalValue.empty(); + OptionalValue projectId = OptionalValue.empty(); + OptionalValue resourceType = OptionalValue.empty(); + OptionalValue eventType = OptionalValue.empty(); + OptionalValue status = OptionalValue.empty(); + OptionalValue resourceId = OptionalValue.empty(); + + @JsonProperty(JSON_PROPERTY_EVENT_ID) + public Builder setEventId(String eventId) { + this.eventId = OptionalValue.of(eventId); + return this; + } + + @JsonProperty(JSON_PROPERTY_TIMESTAMP) + public Builder setTimestamp(Instant timestamp) { + this.timestamp = OptionalValue.of(timestamp); + return this; + } + + @JsonProperty(JSON_PROPERTY_PROJECT_ID) + public Builder setProjectId(String projectId) { + this.projectId = OptionalValue.of(projectId); + return this; + } + + @JsonProperty(value = JSON_PROPERTY_RESOURCE_TYPE, required = true) + public Builder setResourceType(ResourceType resourceType) { + this.resourceType = OptionalValue.of(resourceType); + return this; + } + + @JsonProperty(JSON_PROPERTY_EVENT_TYPE) + public Builder setEventType(EventTypeEnum eventType) { + this.eventType = OptionalValue.of(eventType); + return this; + } + + @JsonProperty(JSON_PROPERTY_STATUS) + public Builder setStatus(StatusEnum status) { + this.status = OptionalValue.of(status); + return this; + } + + @JsonProperty(JSON_PROPERTY_RESOURCE_ID) + public Builder setResourceId(String resourceId) { + this.resourceId = OptionalValue.of(resourceId); + return this; + } + + public NumberOrderSinchEvent build() { + return new NumberOrderSinchEventImpl( + eventId, timestamp, projectId, resourceType, eventType, status, resourceId); + } + } +} diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ResourceType.java b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ResourceType.java index a817cf610..abe133e91 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ResourceType.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/numbers/models/v1/sinchevents/ResourceType.java @@ -11,8 +11,12 @@ public class ResourceType extends EnumDynamic { /** Numbers which are already active and updated with new campaign IDs or service plan IDs. */ public static final ResourceType ACTIVE_NUMBER = new ResourceType("ACTIVE_NUMBER"); + /** Number orders created for buying one or more phone numbers. */ + public static final ResourceType NUMBER_ORDER = new ResourceType("NUMBER_ORDER"); + private static final EnumSupportDynamic ENUM_SUPPORT = - new EnumSupportDynamic<>(ResourceType.class, ResourceType::new, Arrays.asList(ACTIVE_NUMBER)); + new EnumSupportDynamic<>( + ResourceType.class, ResourceType::new, Arrays.asList(ACTIVE_NUMBER, NUMBER_ORDER)); private ResourceType(String value) { super(value); diff --git a/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/events/AppEventDtoTest.java b/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/events/AppEventDtoTest.java index 43ea1edc7..e75be31fc 100644 --- a/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/events/AppEventDtoTest.java +++ b/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/events/AppEventDtoTest.java @@ -19,6 +19,8 @@ import com.sinch.sdk.domains.conversation.models.v1.events.types.ComposingEventImpl; import com.sinch.sdk.domains.conversation.models.v1.events.types.GenericEventDtoTest; import com.sinch.sdk.domains.conversation.models.v1.events.types.GenericEventImpl; +import com.sinch.sdk.domains.conversation.models.v1.events.types.ReadMessageEventDtoTest; +import com.sinch.sdk.domains.conversation.models.v1.events.types.ReadMessageEventImpl; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.skyscreamer.jsonassert.JSONAssert; @@ -38,6 +40,12 @@ public class AppEventDtoTest extends ConversationBaseTest { @GivenTextResource("domains/conversation/v1/events/types/ComposingEndEventDto.json") String jsonComposingEndEvent; + @GivenJsonResource("domains/conversation/v1/events/types/ReadMessageEventDto.json") + AppEventInternalImpl dtoReadMessageEvent; + + @GivenTextResource("domains/conversation/v1/events/types/ReadMessageEventDto.json") + String jsonReadMessageEvent; + @GivenJsonResource("domains/conversation/v1/events/types/CommentReplyEventDto.json") AppEventInternalImpl dtoCommentReplyEvent; @@ -68,6 +76,9 @@ public class AppEventDtoTest extends ConversationBaseTest { public static AppEventInternalImpl expectedComposingEndEventDto = new AppEventInternalImpl((ComposingEndEventImpl) ComposingEndEventDtoTest.expectedDto); + public static AppEventInternalImpl expectedReadMessageEventDto = + new AppEventInternalImpl((ReadMessageEventImpl) ReadMessageEventDtoTest.expectedDto); + public static AppEventInternalImpl expectedCommentReplyEventDto = new AppEventInternalImpl((CommentReplyEventImpl) CommentReplyEventDtoTest.expectedDto); @@ -102,6 +113,17 @@ void deserializeComposingEndDEventto() { TestHelpers.recursiveEquals(dtoComposingEndEvent, expectedComposingEndEventDto); } + @Test + void serializeReadMessageEventDto() throws JsonProcessingException, JSONException { + String serializedString = objectMapper.writeValueAsString(expectedReadMessageEventDto); + JSONAssert.assertEquals(jsonReadMessageEvent, serializedString, true); + } + + @Test + void deserializeReadMessageEventDto() { + TestHelpers.recursiveEquals(dtoReadMessageEvent, expectedReadMessageEventDto); + } + @Test void serializeCommentReplyEventDto() throws JsonProcessingException, JSONException { String serializedString = objectMapper.writeValueAsString(expectedCommentReplyEventDto); diff --git a/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEventDtoTest.java b/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEventDtoTest.java new file mode 100644 index 000000000..2768c9777 --- /dev/null +++ b/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/events/types/ReadMessageEventDtoTest.java @@ -0,0 +1,42 @@ +package com.sinch.sdk.domains.conversation.models.v1.events.types; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.adelean.inject.resources.junit.jupiter.GivenJsonResource; +import com.adelean.inject.resources.junit.jupiter.GivenTextResource; +import com.adelean.inject.resources.junit.jupiter.TestWithResources; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.core.TestHelpers; +import com.sinch.sdk.domains.conversation.api.v1.adapters.ConversationBaseTest; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +@TestWithResources +public class ReadMessageEventDtoTest extends ConversationBaseTest { + + @GivenJsonResource("domains/conversation/v1/events/types/ReadMessageEventDto.json") + ReadMessageEvent dto; + + @GivenTextResource("domains/conversation/v1/events/types/ReadMessageEventDto.json") + String json; + + public static ReadMessageEvent expectedDto = ReadMessageEvent.READ_MESSAGE_EVENT; + + @Test + void serialize() throws JsonProcessingException, JSONException { + String serializedString = objectMapper.writeValueAsString(expectedDto); + JSONAssert.assertEquals(json, serializedString, true); + } + + @Test + void deserialize() { + TestHelpers.recursiveEquals(dto, expectedDto); + } + + @Test + void serializeConstantKeepsEventKey() throws JsonProcessingException { + assertThat(objectMapper.writeValueAsString(ReadMessageEvent.READ_MESSAGE_EVENT)) + .isEqualTo("{\"read_message_event\":{}}"); + } +} diff --git a/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceMessageDtoTest.java b/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceMessageDtoTest.java index 1215241f3..73871402c 100644 --- a/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceMessageDtoTest.java +++ b/openapi-contracts/src/test/java/com/sinch/sdk/domains/conversation/models/v1/messages/types/choice/ChoiceMessageDtoTest.java @@ -89,6 +89,27 @@ public class ChoiceMessageDtoTest extends ConversationBaseTest { ChoiceAdditionalProperties.builder().setWhatsappFooter("My whatsapp footer").build()) .build(); + public static ChoiceMessage choiceMessageWithDisplayModeDto = + ChoiceMessage.builder() + .setTextMessage(TextMessageDtoTest.textMessageDto) + .setChoices( + Arrays.asList( + Choice.builder() + .setMessage(TextMessageDtoTest.textMessageDto) + .setPostbackData("postback persistent data value") + .setDisplayMode(DisplayMode.PERSISTENT) + .build(), + Choice.builder() + .setMessage(UrlMessageDtoTest.urlMessageDto) + .setPostbackData("postback unspecified data value") + .setDisplayMode(DisplayMode.DISPLAY_MODE_UNSPECIFIED) + .build(), + Choice.builder() + .setMessage(CallMessageDtoTest.callMessageDto) + .setPostbackData("postback without display mode value") + .build())) + .build(); + @GivenTextResource("/domains/conversation/v1/messages/types/choice/ChoiceMessageDto.json") String jsonChoiceMessageDto; @@ -96,6 +117,10 @@ public class ChoiceMessageDtoTest extends ConversationBaseTest { "/domains/conversation/v1/messages/types/choice/ChoiceMessageWithWhatsappFooterDto.json") String jsonChoiceMessageWithWhatsappFooterDto; + @GivenTextResource( + "/domains/conversation/v1/messages/types/choice/ChoiceMessageWithDisplayModeDto.json") + String jsonChoiceMessageWithDisplayModeDto; + @Test void serializeMessageDto() throws JsonProcessingException, JSONException { String serializedString = objectMapper.writeValueAsString(choiceMessageDto); @@ -124,4 +149,19 @@ void deserializeMessageWhatsappFooterDto() throws JsonProcessingException { TestHelpers.recursiveEquals(deserialized, choiceMessageWithWhatsappFooterDto); } + + @Test + void serializeMessageDisplayModeDto() throws JsonProcessingException, JSONException { + String serializedString = objectMapper.writeValueAsString(choiceMessageWithDisplayModeDto); + + JSONAssert.assertEquals(jsonChoiceMessageWithDisplayModeDto, serializedString, true); + } + + @Test + void deserializeMessageDisplayModeDto() throws JsonProcessingException { + Object deserialized = + objectMapper.readValue(jsonChoiceMessageWithDisplayModeDto, ChoiceMessage.class); + + TestHelpers.recursiveEquals(deserialized, choiceMessageWithDisplayModeDto); + } } diff --git a/openapi-contracts/src/test/java/com/sinch/sdk/domains/numbers/models/v1/SinchEventsDtoTest.java b/openapi-contracts/src/test/java/com/sinch/sdk/domains/numbers/models/v1/SinchEventsDtoTest.java index 915656ca6..9f7a3a3f9 100644 --- a/openapi-contracts/src/test/java/com/sinch/sdk/domains/numbers/models/v1/SinchEventsDtoTest.java +++ b/openapi-contracts/src/test/java/com/sinch/sdk/domains/numbers/models/v1/SinchEventsDtoTest.java @@ -1,5 +1,7 @@ package com.sinch.sdk.domains.numbers.models.v1; +import static org.assertj.core.api.Assertions.assertThat; + import com.adelean.inject.resources.junit.jupiter.GivenTextResource; import com.adelean.inject.resources.junit.jupiter.TestWithResources; import com.fasterxml.jackson.core.JsonProcessingException; @@ -16,10 +18,10 @@ @TestWithResources public class SinchEventsDtoTest extends NumbersBaseTest { - @GivenTextResource("/domains/numbers/v1/sinchevents/number-sinch-event.json") - String numberEventJSON; + @GivenTextResource("/domains/numbers/v1/sinchevents/active-number-sinch-event.json") + String activeNumberEventJSON; - public static NumberSinchEvent numberEvent = + public static NumberSinchEvent activeNumberSinchEvent = NumberSinchEvent.builder() .setEventId("abcd1234efghijklmnop567890") .setTimestamp(DateUtil.failSafeTimeStampToInstant("2023-06-06T07:45:27.78789")) @@ -32,12 +34,48 @@ public class SinchEventsDtoTest extends NumbersBaseTest { .setInternalFailureCode("CRS0018") .build(); + @GivenTextResource("/domains/numbers/v1/sinchevents/number-order-sinch-event.json") + String numberOrderEventJSON; + + public static NumberSinchEvent numberOrderEvent = + NumberSinchEvent.builder() + .setEventId("abcd1234efghijklmnop567890") + .setTimestamp(DateUtil.failSafeTimeStampToInstant("2023-06-06T07:45:27.78789")) + .setProjectId("abcd12ef-ab12-ab12-bc34-abcdef123456") + .setResourceId("01jgkbb8xywmz3hhahd76menqf") + .setResourceType(ResourceType.NUMBER_ORDER) + .setEventType(EventTypeEnum.NUMBER_ORDER_PROCESSING) + .setStatus(StatusEnum.IN_REVIEW) + .build(); + @Test - void deserialize() throws JsonProcessingException { + void deserializeActiveNumberEvent() throws JsonProcessingException { NumberSinchEvent deserializedString = - objectMapper.readValue(numberEventJSON, NumberSinchEvent.class); + objectMapper.readValue(activeNumberEventJSON, NumberSinchEvent.class); + + TestHelpers.recursiveEquals(activeNumberSinchEvent, deserializedString); + } + + @Test + void deserializeNumberOrderEvent() throws JsonProcessingException { + + NumberSinchEvent deserializedString = + objectMapper.readValue(numberOrderEventJSON, NumberSinchEvent.class); + + TestHelpers.recursiveEquals(numberOrderEvent, deserializedString); + } + + @Test + void activeNumberEventIsReadableThroughTheCommonType() throws JsonProcessingException { + + NumberSinchEvent event = objectMapper.readValue(activeNumberEventJSON, NumberSinchEvent.class); - TestHelpers.recursiveEquals(numberEvent, deserializedString); + assertThat(event.getResourceType()).isEqualTo(ResourceType.ACTIVE_NUMBER); + assertThat(event.getEventType()).isEqualTo(EventTypeEnum.PROVISIONING_TO_CAMPAIGN); + assertThat(event.getStatus()).isEqualTo(StatusEnum.FAILED); + assertThat(event.getResourceId()).isEqualTo("+12345612345"); + assertThat(event.getFailureCode()).isEqualTo(FailureCodeEnum.CAMPAIGN_NOT_AVAILABLE); + assertThat(event.getInternalFailureCode()).isEqualTo("CRS0018"); } } diff --git a/openapi-contracts/src/test/resources/domains/conversation/v1/events/types/ReadMessageEventDto.json b/openapi-contracts/src/test/resources/domains/conversation/v1/events/types/ReadMessageEventDto.json new file mode 100644 index 000000000..c5a3b67ab --- /dev/null +++ b/openapi-contracts/src/test/resources/domains/conversation/v1/events/types/ReadMessageEventDto.json @@ -0,0 +1,3 @@ +{ + "read_message_event": {} +} diff --git a/openapi-contracts/src/test/resources/domains/conversation/v1/messages/types/choice/ChoiceMessageWithDisplayModeDto.json b/openapi-contracts/src/test/resources/domains/conversation/v1/messages/types/choice/ChoiceMessageWithDisplayModeDto.json new file mode 100644 index 000000000..b70c7ad5c --- /dev/null +++ b/openapi-contracts/src/test/resources/domains/conversation/v1/messages/types/choice/ChoiceMessageWithDisplayModeDto.json @@ -0,0 +1,31 @@ +{ + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback persistent data value", + "display_mode": "PERSISTENT" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback unspecified data value", + "display_mode": "DISPLAY_MODE_UNSPECIFIED" + }, + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback without display mode value" + } + ] + } +} diff --git a/openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/number-sinch-event.json b/openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/active-number-sinch-event.json similarity index 100% rename from openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/number-sinch-event.json rename to openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/active-number-sinch-event.json diff --git a/openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/number-order-sinch-event.json b/openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/number-order-sinch-event.json new file mode 100644 index 000000000..1f4f58327 --- /dev/null +++ b/openapi-contracts/src/test/resources/domains/numbers/v1/sinchevents/number-order-sinch-event.json @@ -0,0 +1,9 @@ +{ + "eventId": "abcd1234efghijklmnop567890", + "timestamp": "2023-06-06T07:45:27.78789", + "projectId": "abcd12ef-ab12-ab12-bc34-abcdef123456", + "resourceId": "01jgkbb8xywmz3hhahd76menqf", + "resourceType": "NUMBER_ORDER", + "eventType": "NUMBER_ORDER_PROCESSING", + "status": "IN_REVIEW" +} From f06c030aab552650c19b90ccba6d45379cf4d9ce Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Tue, 11 Aug 2026 08:50:14 +0200 Subject: [PATCH 06/21] DEVEXP-1542: Retry-after response header on HTTP429 for OAuthManager and HTTP-date formats added --- CHANGELOG.md | 6 + .../sinch/sdk/auth/adapters/OAuthManager.java | 128 ++++++++---- .../com/sinch/sdk/http/HttpClientApache.java | 6 +- .../sdk/auth/adapters/OAuthManagerTest.java | 197 +++++++++++++++--- .../com/sinch/sdk/core/utils/DateUtil.java | 63 ++++++ .../sinch/sdk/core/utils/DateUtilTest.java | 37 ++++ 6 files changed, 367 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b40fbcddb..2f5a0a017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ All notable changes to the **Sinch Java SDK** are documented in this file. --- ## v2.2.0 - unreleased +### SDK +- **[feature]** `OAuthManager`: honor the `Retry-After` response header on `HTTP 429` +- **[feature]** `DateUtil`: new `HTTPDateStringToInstant` accepting the three HTTP-date formats of RFC 7231 (IMF-fixdate, RFC 850, asctime); +- **[fix]** `OAuthManager`: retry a rate-limited token request 3 times instead of 2 +- **[fix]** `HttpClientApache`: disable Apache automatic retries + ### Conversation - **[feature]** [Events] Support `ReadMessageEvent` app event (WhatsApp only): use `ReadMessageEvent.READ_MESSAGE_EVENT` - **[feature]** [Messages] [Choice] Support new `displayMode` field and `DisplayMode` enum diff --git a/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java b/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java index 81a684ede..058e787c1 100644 --- a/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java +++ b/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java @@ -11,8 +11,11 @@ import com.sinch.sdk.core.http.HttpResponse; import com.sinch.sdk.core.http.HttpStatus; import com.sinch.sdk.core.models.ServerConfiguration; +import com.sinch.sdk.core.utils.DateUtil; import com.sinch.sdk.core.utils.Pair; import com.sinch.sdk.models.UnifiedCredentials; +import java.time.Duration; +import java.time.Instant; import java.util.AbstractMap; import java.util.Collection; import java.util.Collections; @@ -30,12 +33,16 @@ public class OAuthManager implements AuthManager { public static final String BEARER_AUTHENTICATE_RESPONSE_HEADER_KEYWORD = "www-authenticate"; private static final Logger LOGGER = Logger.getLogger(OAuthManager.class.getName()); private static final String AUTH_KEYWORD = "Bearer"; - // Total refresh attempts for 429 and other failures; sufficient given the backoff. - protected static final int MAX_REFRESH_ATTEMPT = 3; private static final double BACKOFF_BASE_SECONDS = 1.0; private static final int BACKOFF_GROWTH = 4; + private static final String RETRY_AFTER_HEADER = "Retry-After"; + + protected static final int MAX_RATE_LIMIT_RETRIES = 3; + + private static final long RETRY_AFTER_JITTER_MILLIS = 250L; + private final ServerConfiguration oAuthServer; private final HttpMapper mapper; private final Supplier httpClientSupplier; @@ -98,21 +105,41 @@ public Collection> getAuthorizationHeaders( new Pair<>("Authorization", AUTH_KEYWORD + " " + currentToken)); } + /** Fetches a token, retrying only while the authentication service reports a rate limit. */ private void refreshToken() { - int attempt = 0; - while (attempt < MAX_REFRESH_ATTEMPT) { - Optional newValue = getNewToken(attempt); - if (newValue.isPresent()) { - token = newValue.get(); + for (int attempt = 0; ; attempt++) { + HttpResponse response = callTokenEndpoint(); + if (response.getCode() != HttpStatus.TOO_MANY_REQUESTS) { + token = extractAccessToken(response); return; } - attempt++; + if (attempt >= MAX_RATE_LIMIT_RETRIES) { + throw new ApiAuthException( + "Token refresh failed: rate limited by the authentication service (HTTP 429) after " + + (MAX_RATE_LIMIT_RETRIES + 1) + + " attempts"); + } + long sleepMillis = computeBackoffMillis(response, attempt); + LOGGER.fine( + "Rate limited (HTTP 429) during token refresh, attempt " + + (attempt + 1) + + "/" + + (MAX_RATE_LIMIT_RETRIES + 1) + + ", waiting " + + sleepMillis + + "ms before retrying"); + try { + Thread.sleep(sleepMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ApiAuthException("Token refresh interrupted"); + } } - throw new ApiAuthException("Unable to get new token"); } - private Optional getNewToken(int attempt) { + /** Calls the token endpoint once. A transport failure or a missing response is final. */ + private HttpResponse callTokenEndpoint() { LOGGER.fine("Refreshing OAuth token"); HttpRequest request = @@ -135,48 +162,75 @@ private Optional getNewToken(int attempt) { if (httpResponse == null) { throw new ApiAuthException("Token refresh failed: no response received"); } + return httpResponse; + } - if (httpResponse.getCode() == HttpStatus.TOO_MANY_REQUESTS) { - // Only back off if another attempt will follow; on the last attempt we give up immediately. - if (attempt < MAX_REFRESH_ATTEMPT - 1) { - long sleepMillis = computeBackoffMillis(attempt); - LOGGER.fine( - "Rate limited (HTTP 429) during token refresh, attempt " - + (attempt + 1) - + "/" - + MAX_REFRESH_ATTEMPT - + ", waiting " - + sleepMillis - + "ms before next attempt"); - try { - Thread.sleep(sleepMillis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ApiAuthException("Token refresh interrupted"); - } - } - return Optional.empty(); - } + private String extractAccessToken(HttpResponse response) { - if (!HttpStatus.isSuccessfulStatus(httpResponse.getCode())) { - throw new ApiAuthException("Token refresh failed with HTTP " + httpResponse.getCode()); + if (!HttpStatus.isSuccessfulStatus(response.getCode())) { + throw new ApiAuthException("Token refresh failed with HTTP " + response.getCode()); } + BearerAuthResponse authResponse; try { - BearerAuthResponse authResponse = - mapper.deserialize(httpResponse, new TypeReference() {}); - return Optional.ofNullable(authResponse.getAccessToken()); + authResponse = mapper.deserialize(response, new TypeReference() {}); } catch (Exception e) { throw new ApiAuthException( "Token refresh failed: could not deserialize response: " + e.getMessage()); } + + String accessToken = null != authResponse ? authResponse.getAccessToken() : null; + if (null == accessToken || accessToken.trim().isEmpty()) { + throw new ApiAuthException( + "Token refresh failed: the authentication service returned HTTP " + + response.getCode() + + " without an access_token"); + } + return accessToken; } - long computeBackoffMillis(int attempt) { + long computeBackoffMillis(HttpResponse response, int attempt) { + Optional retryAfter = + retryAfterHeader(response).flatMap(OAuthManager::parseRetryAfterMillis); + if (retryAfter.isPresent()) { + return retryAfter.get() + ThreadLocalRandom.current().nextLong(RETRY_AFTER_JITTER_MILLIS + 1); + } double maxDelay = BACKOFF_BASE_SECONDS * Math.pow(BACKOFF_GROWTH, attempt); return (long) (ThreadLocalRandom.current().nextDouble(maxDelay) * 1000); } + private static Optional retryAfterHeader(HttpResponse response) { + return response.getHeaders().entrySet().stream() + .filter(entry -> RETRY_AFTER_HEADER.equalsIgnoreCase(entry.getKey())) + .map(Map.Entry::getValue) + .filter(values -> null != values && !values.isEmpty()) + .map(values -> values.get(0)) + .findFirst(); + } + + private static Optional parseRetryAfterMillis(String value) { + String trimmed = null == value ? "" : value.trim(); + if (trimmed.isEmpty()) { + return Optional.empty(); + } + try { + double seconds = Double.parseDouble(trimmed); + boolean usable = seconds >= 0 && seconds < Long.MAX_VALUE / 1000d; + return usable ? Optional.of((long) (seconds * 1000)) : Optional.empty(); + } catch (NumberFormatException notDeltaSeconds) { + } + // Not a number, so try the HTTP-date form: all three RFC 7231 spellings are accepted. + Instant retryAt = DateUtil.HTTPDateStringToInstant(trimmed); + if (null == retryAt) { + return Optional.empty(); + } + try { + return Optional.of(Math.max(0, Duration.between(Instant.now(), retryAt).toMillis())); + } catch (ArithmeticException tooFarInTheFuture) { + return Optional.empty(); + } + } + public boolean validateAuthenticatedRequest( String method, String path, Map headers, String jsonPayload) { LOGGER.severe("checkAuthentication not implemented"); diff --git a/client/src/main/com/sinch/sdk/http/HttpClientApache.java b/client/src/main/com/sinch/sdk/http/HttpClientApache.java index 365710d60..fe69826e4 100644 --- a/client/src/main/com/sinch/sdk/http/HttpClientApache.java +++ b/client/src/main/com/sinch/sdk/http/HttpClientApache.java @@ -76,13 +76,15 @@ public HttpClientApache(HttpProxyConfiguration proxyConfiguration) { private static CloseableHttpClient buildHttpClient(HttpProxyConfiguration proxyConfiguration) { if (proxyConfiguration == null) { - return HttpClients.createDefault(); + return HttpClients.custom().disableAutomaticRetries().build(); } HttpHost proxyHost = new HttpHost(proxyConfiguration.getHostname(), proxyConfiguration.getPort()); HttpClientBuilder builder = - HttpClients.custom().setRoutePlanner(new DefaultProxyRoutePlanner(proxyHost)); + HttpClients.custom() + .disableAutomaticRetries() + .setRoutePlanner(new DefaultProxyRoutePlanner(proxyHost)); if (proxyConfiguration.getUsername().isPresent()) { // getPassword() returns a defensive copy of the internal array; HC5 receives that copy and diff --git a/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java b/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java index e3cd3fea2..355388222 100644 --- a/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java +++ b/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java @@ -17,8 +17,22 @@ import com.sinch.sdk.core.utils.Pair; import com.sinch.sdk.models.UnifiedCredentials; import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -137,7 +151,7 @@ void setup() { @Test void retriesOn429ThenSucceeds() { - doReturn(0L).when(spyAuthManager).computeBackoffMillis(anyInt()); + doReturn(0L).when(spyAuthManager).computeBackoffMillis(any(), anyInt()); HttpResponse rateLimited = new HttpResponse(429, "Too Many Requests", Collections.emptyMap(), null); HttpResponse ok = @@ -149,39 +163,41 @@ void retriesOn429ThenSucceeds() { spyAuthManager.getAuthorizationHeaders(null, null, null, null); assertNotNull(headers); - verify(httpClient, times(OAuthManager.MAX_REFRESH_ATTEMPT)).invokeAPI(any(), any(), any()); + verify(httpClient, times(3)).invokeAPI(any(), any(), any()); } @Test void givesUpAfterMaxRetries() { - doReturn(0L).when(spyAuthManager).computeBackoffMillis(anyInt()); + doReturn(0L).when(spyAuthManager).computeBackoffMillis(any(), anyInt()); HttpResponse rateLimited = new HttpResponse(429, "Too Many Requests", Collections.emptyMap(), null); - when(httpClient.invokeAPI(any(), any(), any())).thenReturn(rateLimited); - ApiAuthException exception = assertThrows( ApiAuthException.class, () -> spyAuthManager.getAuthorizationHeaders(null, null, null, null)); assertTrue( - exception.getMessage().startsWith("Unable to get new token"), - "expected the give-up message, got: " + exception.getMessage()); - - verify(httpClient, times(OAuthManager.MAX_REFRESH_ATTEMPT)).invokeAPI(any(), any(), any()); + exception.getMessage().contains("rate limited by the authentication service (HTTP 429)"), + "expected the give-up message to name the rate limit, got: " + exception.getMessage()); + verify(httpClient, times(OAuthManager.MAX_RATE_LIMIT_RETRIES + 1)) + .invokeAPI(any(), any(), any()); + verify(spyAuthManager, times(OAuthManager.MAX_RATE_LIMIT_RETRIES)) + .computeBackoffMillis(any(), anyInt()); } @Test - void noBackoffOnNon429Failure() { - HttpResponse serverError = - new HttpResponse(500, "Internal Server Error", Collections.emptyMap(), new byte[0]); - when(httpClient.invokeAPI(any(), any(), any())).thenReturn(serverError); - - assertThrows( - ApiAuthException.class, - () -> spyAuthManager.getAuthorizationHeaders(null, null, null, null)); - - verify(spyAuthManager, never()).computeBackoffMillis(anyInt()); + void feedsTheRateLimitedResponseToTheBackoff() { + doReturn(0L).when(spyAuthManager).computeBackoffMillis(any(), anyInt()); + Map> headers = new HashMap<>(); + headers.put("Retry-After", Collections.singletonList("5")); + HttpResponse rateLimited = new HttpResponse(429, "Too Many Requests", headers, null); + HttpResponse ok = + new HttpResponse(200, "foo message", null, jsonResponse.getBytes(StandardCharsets.UTF_8)); + when(httpClient.invokeAPI(any(), any(), any())).thenReturn(rateLimited, ok); + spyAuthManager.getAuthorizationHeaders(null, null, null, null); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpResponse.class); + verify(spyAuthManager).computeBackoffMillis(captor.capture(), eq(0)); + assertSame(rateLimited, captor.getValue()); } } @@ -215,7 +231,7 @@ void networkErrorThrowsWithoutRetryOrBackoff() { "expected a network/client-error cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); - verify(spyAuthManager, never()).computeBackoffMillis(anyInt()); + verify(spyAuthManager, never()).computeBackoffMillis(any(), anyInt()); } @Test @@ -233,7 +249,26 @@ void nonSuccessfulStatusThrowsWithoutRetryOrBackoff() { "expected an HTTP-status cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); - verify(spyAuthManager, never()).computeBackoffMillis(anyInt()); + verify(spyAuthManager, never()).computeBackoffMillis(any(), anyInt()); + } + + @Test + void successWithoutAccessTokenThrowsWithoutRetryOrBackoff() { + HttpResponse noToken = + new HttpResponse( + 200, "foo message", null, "{\"expires_in\":3600}".getBytes(StandardCharsets.UTF_8)); + when(httpClient.invokeAPI(any(), any(), any())).thenReturn(noToken); + + ApiAuthException exception = + assertThrows( + ApiAuthException.class, + () -> spyAuthManager.getAuthorizationHeaders(null, null, null, null)); + assertTrue( + exception.getMessage().contains("without an access_token"), + "expected the missing-token cause, got: " + exception.getMessage()); + + verify(httpClient, times(1)).invokeAPI(any(), any(), any()); + verify(spyAuthManager, never()).computeBackoffMillis(any(), anyInt()); } @Test @@ -251,30 +286,130 @@ void deserializationFailureThrowsWithoutRetryOrBackoff() { "expected a deserialization cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); - verify(spyAuthManager, never()).computeBackoffMillis(anyInt()); + verify(spyAuthManager, never()).computeBackoffMillis(any(), anyInt()); } } @Nested - class ComputeBackoff { + class ConcurrentRefresh { @Test - void exponentialGrowth() { - OAuthManager manager = + void concurrentCallersShareASingleRefresh() throws Exception { + OAuthManager spyAuthManager = + spy( + new OAuthManager( + credentials, + new ServerConfiguration("OAuth url"), + HttpMapper.getInstance(), + () -> httpClient)); + doReturn(0L).when(spyAuthManager).computeBackoffMillis(any(), anyInt()); + HttpResponse rateLimited = + new HttpResponse(429, "Too Many Requests", Collections.emptyMap(), null); + HttpResponse ok = + new HttpResponse(200, "foo message", null, jsonResponse.getBytes(StandardCharsets.UTF_8)); + when(httpClient.invokeAPI(any(), any(), any())).thenReturn(rateLimited, ok); + + int callers = 10; + ExecutorService pool = Executors.newFixedThreadPool(callers); + CountDownLatch startTogether = new CountDownLatch(1); + List>>> results = new ArrayList<>(); + try { + for (int i = 0; i < callers; i++) { + results.add( + pool.submit( + () -> { + startTogether.await(); + return spyAuthManager.getAuthorizationHeaders(null, null, null, null); + })); + } + startTogether.countDown(); + + for (Future>> result : results) { + Collection> headers = result.get(10, TimeUnit.SECONDS); + assertEquals("Bearer token value", headers.iterator().next().getRight()); + } + } finally { + pool.shutdownNow(); + } + + // one shared sequence for all ten callers: the 429, then the retry that succeeded + verify(httpClient, times(2)).invokeAPI(any(), any(), any()); + } + } + + @Nested + class ComputeBackoff { + + private OAuthManager manager; + + @BeforeEach + void setup() { + manager = new OAuthManager( credentials, new ServerConfiguration("OAuth url"), HttpMapper.getInstance(), () -> httpClient); + } - long b0 = manager.computeBackoffMillis(0); - assertTrue(b0 >= 0 && b0 <= 1000); + @Test + void honorsRetryAfter() { + assertBetween(5_000, 5_250, backoff("Retry-After", "5", 0)); + assertBetween( + 4_000, 5_250, backoff("Retry-After", httpDate(Instant.now().plusSeconds(5)), 0)); + + // A zero delay is still a delay, and a date already past means the window has reopened. + assertBetween(0, 250, backoff("Retry-After", "0", 0)); + assertBetween(0, 250, backoff("Retry-After", httpDate(Instant.now().minusSeconds(3600)), 0)); - long b1 = manager.computeBackoffMillis(1); - assertTrue(b1 >= 0 && b1 <= 4000); + // HTTP/2 lower-cases header names, HTTP/1.1 servers usually do not; both must be honored. + assertBetween(5_000, 5_250, backoff("retry-after", "5", 0)); - long b2 = manager.computeBackoffMillis(2); - assertTrue(b2 >= 0 && b2 <= 16000); + // The two obsolete formats a recipient must still accept (RFC 7231 section 7.1.1.1) + assertBetween(4_000, 5_250, backoff("Retry-After", obsoleteDate("RFC850"), 0)); + assertBetween(4_000, 5_250, backoff("Retry-After", obsoleteDate("asctime"), 0)); + } + + @Test + void fallsBackToExponentialBackoffWhenRetryAfterIsUnusable() { + for (String value : new String[] {"", " ", "abc", "-3", "NaN", "Infinity", "1e30"}) { + assertBetween(0, 1_000, backoff("Retry-After", value, 0)); + } + } + + @Test + void exponentialBackoffGrowsWithEachAttempt() { + assertBetween(0, 1_000, backoff(null, null, 0)); + assertBetween(0, 4_000, backoff(null, null, 1)); + assertBetween(0, 16_000, backoff(null, null, 2)); + } + + private long backoff(String headerName, String headerValue, int attempt) { + Map> headers = new HashMap<>(); + if (null != headerName && null != headerValue) { + headers.put(headerName, Collections.singletonList(headerValue)); + } + return manager.computeBackoffMillis( + new HttpResponse(429, "Too Many Requests", headers, null), attempt); + } + + private String httpDate(Instant instant) { + return DateTimeFormatter.RFC_1123_DATE_TIME.format( + ZonedDateTime.ofInstant(instant, ZoneOffset.UTC)); + } + + /** Formats "five seconds from now" in one of the two obsolete HTTP-date forms. */ + private String obsoleteDate(String form) { + ZonedDateTime when = ZonedDateTime.ofInstant(Instant.now().plusSeconds(5), ZoneOffset.UTC); + String pattern = + "RFC850".equals(form) ? "EEEE, dd-MMM-yy HH:mm:ss 'GMT'" : "EEE MMM ppd HH:mm:ss yyyy"; + return DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH).format(when); + } + + private void assertBetween(long lowInclusive, long highInclusive, long actual) { + assertTrue( + actual >= lowInclusive && actual <= highInclusive, + "expected a value in [" + lowInclusive + ", " + highInclusive + "], got: " + actual); } } } diff --git a/core/src/main/com/sinch/sdk/core/utils/DateUtil.java b/core/src/main/com/sinch/sdk/core/utils/DateUtil.java index 5b4c8ed96..eaa3f30f8 100644 --- a/core/src/main/com/sinch/sdk/core/utils/DateUtil.java +++ b/core/src/main/com/sinch/sdk/core/utils/DateUtil.java @@ -7,7 +7,10 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; +import java.util.Locale; import java.util.logging.Logger; /** Utility class for Date */ @@ -19,6 +22,28 @@ public class DateUtil { private static final DateTimeFormatter RFC822_GMT_FORMAT = DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss").withZone(ZoneId.of("GMT")); + // RFC 7231 section 7.1.1.1: a two-digit RFC 850 year that looks more than 50 years ahead must be + // read as the most recent past year with the same last two digits, so "94" gives 1994 not 2094. + private static final int RFC850_YEAR_PIVOT = ZonedDateTime.now(ZoneOffset.UTC).getYear() - 50; + + private static final DateTimeFormatter RFC850_FORMAT = + new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern("EEEE, dd-MMM-") + .appendValueReduced(ChronoField.YEAR, 2, 2, RFC850_YEAR_PIVOT) + .appendPattern(" HH:mm:ss z") + .toFormatter(Locale.ENGLISH); + + private static final DateTimeFormatter ASCTIME_FORMAT = + new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern("EEE MMM ") + .padNext(2) + .appendValue(ChronoField.DAY_OF_MONTH) + .appendPattern(" HH:mm:ss yyyy") + .toFormatter(Locale.ENGLISH) + .withZone(ZoneOffset.UTC); + private static final Logger LOGGER = Logger.getLogger(DateUtil.class.getName()); private DateUtil() {} @@ -156,6 +181,44 @@ public static Instant RFC822StringToInstant(String value) { return null; } + public static Instant HTTPDateStringToInstant(String value) { + + String trimmed = null == value ? "" : value.trim(); + + if (trimmed.isEmpty()) { + return null; + } + + // the preferred form first, then the two obsolete ones + Instant parsed = parseRFC822(trimmed); + if (null != parsed) { + return parsed; + } + + parsed = parseRFC850(trimmed); + if (null != parsed) { + return parsed; + } + + return parseAsctime(trimmed); + } + + private static Instant parseRFC850(String trimmed) { + try { + return ZonedDateTime.parse(trimmed, RFC850_FORMAT).toInstant(); + } catch (DateTimeParseException _unused) { + return null; + } + } + + private static Instant parseAsctime(String trimmed) { + try { + return ZonedDateTime.parse(trimmed, ASCTIME_FORMAT).toInstant(); + } catch (DateTimeParseException _unused) { + return null; + } + } + private static Instant parseRFC822(String trimmed) { try { return Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(trimmed)); diff --git a/core/src/test/java/com/sinch/sdk/core/utils/DateUtilTest.java b/core/src/test/java/com/sinch/sdk/core/utils/DateUtilTest.java index da1a6f857..d9d0f538e 100644 --- a/core/src/test/java/com/sinch/sdk/core/utils/DateUtilTest.java +++ b/core/src/test/java/com/sinch/sdk/core/utils/DateUtilTest.java @@ -122,4 +122,41 @@ void RFC822Invalid() { Instant instant = DateUtil.RFC822StringToInstant("Mon, 12 Jan 2006 15:04:05 +0100"); assertNull(instant); } + + @Test + void HTTPDateAcceptsTheThreeRequiredFormats() { + // the three spellings RFC 7231 section 7.1.1.1 requires a recipient to accept + assertEquals( + "1994-11-06T08:49:37Z", + DateUtil.HTTPDateStringToInstant("Sun, 06 Nov 1994 08:49:37 GMT").toString()); + assertEquals( + "1994-11-06T08:49:37Z", + DateUtil.HTTPDateStringToInstant("Sunday, 06-Nov-94 08:49:37 GMT").toString()); + assertEquals( + "1994-11-06T08:49:37Z", + DateUtil.HTTPDateStringToInstant("Sun Nov 6 08:49:37 1994").toString()); + } + + @Test + void HTTPDateResolvesTwoDigitYearsIntoThePast() { + // "94" must resolve to 1994, not 2094: the accepted window ends 50 years from now + Instant instant = DateUtil.HTTPDateStringToInstant("Sunday, 06-Nov-94 08:49:37 GMT"); + assertTrue(instant.isBefore(Instant.now()), "expected a past date, got: " + instant); + } + + @Test + void HTTPDateAcceptsATwoDigitDayInAsctime() { + assertEquals( + "1994-11-16T08:49:37Z", + DateUtil.HTTPDateStringToInstant("Wed Nov 16 08:49:37 1994").toString()); + } + + @Test + void HTTPDateRejectsAnythingElse() { + assertNull(DateUtil.HTTPDateStringToInstant(null)); + assertNull(DateUtil.HTTPDateStringToInstant(" ")); + assertNull(DateUtil.HTTPDateStringToInstant("not-a-date")); + // 12th of January 2006 is not a Monday (it was a Thursday) + assertNull(DateUtil.HTTPDateStringToInstant("Mon, 12 Jan 2006 15:04:05 GMT")); + } } From e8ca8ea43e02f489fc43d2af572d32b1590db17f Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Wed, 12 Aug 2026 08:58:15 +0200 Subject: [PATCH 07/21] Addressed comments --- CHANGELOG.md | 8 ++--- .../sinch/sdk/auth/adapters/OAuthManager.java | 34 +++++++++---------- .../sdk/auth/adapters/OAuthManagerTest.java | 24 +++++++------ .../com/sinch/sdk/core/utils/DateUtil.java | 32 +++++++++-------- .../utils/databind/RFC822FormSerializer.java | 5 ++- .../sinch/sdk/core/utils/DateUtilTest.java | 26 +++++++------- 6 files changed, 67 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5a0a017..c657b5f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,10 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ## v2.2.0 - unreleased ### SDK -- **[feature]** `OAuthManager`: honor the `Retry-After` response header on `HTTP 429` -- **[feature]** `DateUtil`: new `HTTPDateStringToInstant` accepting the three HTTP-date formats of RFC 7231 (IMF-fixdate, RFC 850, asctime); -- **[fix]** `OAuthManager`: retry a rate-limited token request 3 times instead of 2 -- **[fix]** `HttpClientApache`: disable Apache automatic retries +- **[feature]** Default Apache HttpClient retry policy disabled in favor of a dedicated SDK implementation: + - honor the `Retry-After` response header on `HTTP 429` and fallback to exponential backoff if not present + - max retry changed from default Apache HttpClient (`1`) to SDK implementation: `3` +- **[feature]** `DateUtil`: new `RFC7231StringToInstant` accepting the three HTTP-date formats of RFC 7231 (IMF-fixdate, RFC 850, asctime) ### Conversation - **[feature]** [Events] Support `ReadMessageEvent` app event (WhatsApp only): use `ReadMessageEvent.READ_MESSAGE_EVENT` diff --git a/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java b/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java index 058e787c1..3af8bb466 100644 --- a/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java +++ b/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java @@ -13,6 +13,7 @@ import com.sinch.sdk.core.models.ServerConfiguration; import com.sinch.sdk.core.utils.DateUtil; import com.sinch.sdk.core.utils.Pair; +import com.sinch.sdk.core.utils.StringUtil; import com.sinch.sdk.models.UnifiedCredentials; import java.time.Duration; import java.time.Instant; @@ -96,8 +97,8 @@ public Collection> getAuthorizationHeaders( synchronized (this) { currentToken = token; if (currentToken == null) { - refreshToken(); - currentToken = token; + currentToken = getNewToken(); + token = currentToken; } } } @@ -106,13 +107,12 @@ public Collection> getAuthorizationHeaders( } /** Fetches a token, retrying only while the authentication service reports a rate limit. */ - private void refreshToken() { + private String getNewToken() { for (int attempt = 0; ; attempt++) { - HttpResponse response = callTokenEndpoint(); + HttpResponse response = callOAuthEndpoint(); if (response.getCode() != HttpStatus.TOO_MANY_REQUESTS) { - token = extractAccessToken(response); - return; + return extractAccessToken(response); } if (attempt >= MAX_RATE_LIMIT_RETRIES) { throw new ApiAuthException( @@ -138,10 +138,10 @@ private void refreshToken() { } } - /** Calls the token endpoint once. A transport failure or a missing response is final. */ - private HttpResponse callTokenEndpoint() { + /** Performs the OAuth request once. A transport failure or a missing response is final. */ + private HttpResponse callOAuthEndpoint() { - LOGGER.fine("Refreshing OAuth token"); + LOGGER.fine("Calling OAuth endpoint"); HttpRequest request = new HttpRequest( null, @@ -157,10 +157,10 @@ private HttpResponse callTokenEndpoint() { httpResponse = httpClientSupplier.get().invokeAPI(oAuthServer, authManagers, request); } catch (Exception e) { throw new ApiAuthException( - "Token refresh failed: network or client error: " + e.getMessage()); + "OAuth request failed: network or client error: " + e.getMessage()); } if (httpResponse == null) { - throw new ApiAuthException("Token refresh failed: no response received"); + throw new ApiAuthException("OAuth request failed: no response received"); } return httpResponse; } @@ -168,7 +168,7 @@ private HttpResponse callTokenEndpoint() { private String extractAccessToken(HttpResponse response) { if (!HttpStatus.isSuccessfulStatus(response.getCode())) { - throw new ApiAuthException("Token refresh failed with HTTP " + response.getCode()); + throw new ApiAuthException("Unable to extract token with HTTP " + response.getCode()); } BearerAuthResponse authResponse; @@ -176,15 +176,15 @@ private String extractAccessToken(HttpResponse response) { authResponse = mapper.deserialize(response, new TypeReference() {}); } catch (Exception e) { throw new ApiAuthException( - "Token refresh failed: could not deserialize response: " + e.getMessage()); + "Unable to extract token: could not deserialize response: " + e.getMessage()); } String accessToken = null != authResponse ? authResponse.getAccessToken() : null; - if (null == accessToken || accessToken.trim().isEmpty()) { + if (StringUtil.isEmpty(accessToken)) { throw new ApiAuthException( - "Token refresh failed: the authentication service returned HTTP " + "Unable to extract token: the HTTP " + response.getCode() - + " without an access_token"); + + " response carries no access_token"); } return accessToken; } @@ -220,7 +220,7 @@ private static Optional parseRetryAfterMillis(String value) { } catch (NumberFormatException notDeltaSeconds) { } // Not a number, so try the HTTP-date form: all three RFC 7231 spellings are accepted. - Instant retryAt = DateUtil.HTTPDateStringToInstant(trimmed); + Instant retryAt = DateUtil.RFC7231StringToInstant(trimmed); if (null == retryAt) { return Optional.empty(); } diff --git a/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java b/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java index 355388222..4a8e76763 100644 --- a/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java +++ b/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java @@ -14,6 +14,7 @@ import com.sinch.sdk.core.http.HttpRequest; import com.sinch.sdk.core.http.HttpResponse; import com.sinch.sdk.core.models.ServerConfiguration; +import com.sinch.sdk.core.utils.DateUtil; import com.sinch.sdk.core.utils.Pair; import com.sinch.sdk.models.UnifiedCredentials; import java.nio.charset.StandardCharsets; @@ -227,7 +228,7 @@ void networkErrorThrowsWithoutRetryOrBackoff() { ApiAuthException.class, () -> spyAuthManager.getAuthorizationHeaders(null, null, null, null)); assertTrue( - exception.getMessage().startsWith("Token refresh failed: network or client error"), + exception.getMessage().startsWith("OAuth request failed: network or client error"), "expected a network/client-error cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); @@ -245,7 +246,7 @@ void nonSuccessfulStatusThrowsWithoutRetryOrBackoff() { ApiAuthException.class, () -> spyAuthManager.getAuthorizationHeaders(null, null, null, null)); assertTrue( - exception.getMessage().startsWith("Token refresh failed with HTTP 503"), + exception.getMessage().startsWith("Unable to extract token with HTTP 503"), "expected an HTTP-status cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); @@ -264,7 +265,7 @@ void successWithoutAccessTokenThrowsWithoutRetryOrBackoff() { ApiAuthException.class, () -> spyAuthManager.getAuthorizationHeaders(null, null, null, null)); assertTrue( - exception.getMessage().contains("without an access_token"), + exception.getMessage().contains("carries no access_token"), "expected the missing-token cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); @@ -282,7 +283,7 @@ void deserializationFailureThrowsWithoutRetryOrBackoff() { ApiAuthException.class, () -> spyAuthManager.getAuthorizationHeaders(null, null, null, null)); assertTrue( - exception.getMessage().startsWith("Token refresh failed: could not deserialize response"), + exception.getMessage().startsWith("Unable to extract token: could not deserialize"), "expected a deserialization cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); @@ -356,11 +357,17 @@ void setup() { void honorsRetryAfter() { assertBetween(5_000, 5_250, backoff("Retry-After", "5", 0)); assertBetween( - 4_000, 5_250, backoff("Retry-After", httpDate(Instant.now().plusSeconds(5)), 0)); + 4_000, + 5_250, + backoff("Retry-After", DateUtil.instantToRFC822String(Instant.now().plusSeconds(5)), 0)); // A zero delay is still a delay, and a date already past means the window has reopened. assertBetween(0, 250, backoff("Retry-After", "0", 0)); - assertBetween(0, 250, backoff("Retry-After", httpDate(Instant.now().minusSeconds(3600)), 0)); + assertBetween( + 0, + 250, + backoff( + "Retry-After", DateUtil.instantToRFC822String(Instant.now().minusSeconds(3600)), 0)); // HTTP/2 lower-cases header names, HTTP/1.1 servers usually do not; both must be honored. assertBetween(5_000, 5_250, backoff("retry-after", "5", 0)); @@ -393,11 +400,6 @@ private long backoff(String headerName, String headerValue, int attempt) { new HttpResponse(429, "Too Many Requests", headers, null), attempt); } - private String httpDate(Instant instant) { - return DateTimeFormatter.RFC_1123_DATE_TIME.format( - ZonedDateTime.ofInstant(instant, ZoneOffset.UTC)); - } - /** Formats "five seconds from now" in one of the two obsolete HTTP-date forms. */ private String obsoleteDate(String form) { ZonedDateTime when = ZonedDateTime.ofInstant(Instant.now().plusSeconds(5), ZoneOffset.UTC); diff --git a/core/src/main/com/sinch/sdk/core/utils/DateUtil.java b/core/src/main/com/sinch/sdk/core/utils/DateUtil.java index eaa3f30f8..3ce8cab9f 100644 --- a/core/src/main/com/sinch/sdk/core/utils/DateUtil.java +++ b/core/src/main/com/sinch/sdk/core/utils/DateUtil.java @@ -22,18 +22,6 @@ public class DateUtil { private static final DateTimeFormatter RFC822_GMT_FORMAT = DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss").withZone(ZoneId.of("GMT")); - // RFC 7231 section 7.1.1.1: a two-digit RFC 850 year that looks more than 50 years ahead must be - // read as the most recent past year with the same last two digits, so "94" gives 1994 not 2094. - private static final int RFC850_YEAR_PIVOT = ZonedDateTime.now(ZoneOffset.UTC).getYear() - 50; - - private static final DateTimeFormatter RFC850_FORMAT = - new DateTimeFormatterBuilder() - .parseCaseInsensitive() - .appendPattern("EEEE, dd-MMM-") - .appendValueReduced(ChronoField.YEAR, 2, 2, RFC850_YEAR_PIVOT) - .appendPattern(" HH:mm:ss z") - .toFormatter(Locale.ENGLISH); - private static final DateTimeFormatter ASCTIME_FORMAT = new DateTimeFormatterBuilder() .parseCaseInsensitive() @@ -144,6 +132,12 @@ private static Instant parseEpochSeconds(String trimmed) { } } + public static String instantToRFC822String(Instant value) { + return (null == value + ? null + : DateTimeFormatter.RFC_1123_DATE_TIME.format(value.atZone(ZoneId.of("UTC")))); + } + /** * Convert String to Instant * @@ -181,7 +175,7 @@ public static Instant RFC822StringToInstant(String value) { return null; } - public static Instant HTTPDateStringToInstant(String value) { + public static Instant RFC7231StringToInstant(String value) { String trimmed = null == value ? "" : value.trim(); @@ -205,12 +199,22 @@ public static Instant HTTPDateStringToInstant(String value) { private static Instant parseRFC850(String trimmed) { try { - return ZonedDateTime.parse(trimmed, RFC850_FORMAT).toInstant(); + return ZonedDateTime.parse(trimmed, rfc850Format()).toInstant(); } catch (DateTimeParseException _unused) { return null; } } + private static DateTimeFormatter rfc850Format() { + return new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern("EEEE, dd-MMM-") + .appendValueReduced( + ChronoField.YEAR, 2, 2, ZonedDateTime.now(ZoneOffset.UTC).getYear() - 50) + .appendPattern(" HH:mm:ss z") + .toFormatter(Locale.ENGLISH); + } + private static Instant parseAsctime(String trimmed) { try { return ZonedDateTime.parse(trimmed, ASCTIME_FORMAT).toInstant(); diff --git a/core/src/main/com/sinch/sdk/core/utils/databind/RFC822FormSerializer.java b/core/src/main/com/sinch/sdk/core/utils/databind/RFC822FormSerializer.java index 51c84e3aa..8129f878f 100644 --- a/core/src/main/com/sinch/sdk/core/utils/databind/RFC822FormSerializer.java +++ b/core/src/main/com/sinch/sdk/core/utils/databind/RFC822FormSerializer.java @@ -1,9 +1,8 @@ package com.sinch.sdk.core.utils.databind; import com.sinch.sdk.core.databind.FormSerializer; +import com.sinch.sdk.core.utils.DateUtil; import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.Map; public class RFC822FormSerializer extends FormSerializer { @@ -14,6 +13,6 @@ public void serialize(Instant in, String fieldName, Map out) { } public static String format(Instant instant) { - return DateTimeFormatter.RFC_1123_DATE_TIME.format(instant.atZone(ZoneId.of("UTC"))); + return DateUtil.instantToRFC822String(instant); } } diff --git a/core/src/test/java/com/sinch/sdk/core/utils/DateUtilTest.java b/core/src/test/java/com/sinch/sdk/core/utils/DateUtilTest.java index d9d0f538e..777d04103 100644 --- a/core/src/test/java/com/sinch/sdk/core/utils/DateUtilTest.java +++ b/core/src/test/java/com/sinch/sdk/core/utils/DateUtilTest.java @@ -124,39 +124,39 @@ void RFC822Invalid() { } @Test - void HTTPDateAcceptsTheThreeRequiredFormats() { + void RFC7231AcceptsTheThreeRequiredFormats() { // the three spellings RFC 7231 section 7.1.1.1 requires a recipient to accept assertEquals( "1994-11-06T08:49:37Z", - DateUtil.HTTPDateStringToInstant("Sun, 06 Nov 1994 08:49:37 GMT").toString()); + DateUtil.RFC7231StringToInstant("Sun, 06 Nov 1994 08:49:37 GMT").toString()); assertEquals( "1994-11-06T08:49:37Z", - DateUtil.HTTPDateStringToInstant("Sunday, 06-Nov-94 08:49:37 GMT").toString()); + DateUtil.RFC7231StringToInstant("Sunday, 06-Nov-94 08:49:37 GMT").toString()); assertEquals( "1994-11-06T08:49:37Z", - DateUtil.HTTPDateStringToInstant("Sun Nov 6 08:49:37 1994").toString()); + DateUtil.RFC7231StringToInstant("Sun Nov 6 08:49:37 1994").toString()); } @Test - void HTTPDateResolvesTwoDigitYearsIntoThePast() { + void RFC7231ResolvesTwoDigitYearsIntoThePast() { // "94" must resolve to 1994, not 2094: the accepted window ends 50 years from now - Instant instant = DateUtil.HTTPDateStringToInstant("Sunday, 06-Nov-94 08:49:37 GMT"); + Instant instant = DateUtil.RFC7231StringToInstant("Sunday, 06-Nov-94 08:49:37 GMT"); assertTrue(instant.isBefore(Instant.now()), "expected a past date, got: " + instant); } @Test - void HTTPDateAcceptsATwoDigitDayInAsctime() { + void RFC7231AcceptsATwoDigitDayInAsctime() { assertEquals( "1994-11-16T08:49:37Z", - DateUtil.HTTPDateStringToInstant("Wed Nov 16 08:49:37 1994").toString()); + DateUtil.RFC7231StringToInstant("Wed Nov 16 08:49:37 1994").toString()); } @Test - void HTTPDateRejectsAnythingElse() { - assertNull(DateUtil.HTTPDateStringToInstant(null)); - assertNull(DateUtil.HTTPDateStringToInstant(" ")); - assertNull(DateUtil.HTTPDateStringToInstant("not-a-date")); + void RFC7231RejectsAnythingElse() { + assertNull(DateUtil.RFC7231StringToInstant(null)); + assertNull(DateUtil.RFC7231StringToInstant(" ")); + assertNull(DateUtil.RFC7231StringToInstant("not-a-date")); // 12th of January 2006 is not a Monday (it was a Thursday) - assertNull(DateUtil.HTTPDateStringToInstant("Mon, 12 Jan 2006 15:04:05 GMT")); + assertNull(DateUtil.RFC7231StringToInstant("Mon, 12 Jan 2006 15:04:05 GMT")); } } From 8abe3f3b5466c8754496a052f3f3078a2864c68e Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 14 Aug 2026 17:35:05 +0200 Subject: [PATCH 08/21] Removed empty empty E2E tests --- .../e2e/domains/numbers/v1/SinchEventsSteps.java | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/numbers/v1/SinchEventsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/numbers/v1/SinchEventsSteps.java index fdee1981f..e94bdc45a 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/numbers/v1/SinchEventsSteps.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/numbers/v1/SinchEventsSteps.java @@ -26,9 +26,6 @@ public class SinchEventsSteps { static final String WEBHOOKS_PATH = Config.NUMBERS_HOST_NAME + "/webhooks/numbers/"; static final String SECRET = "strongPa$$PhraseWith36CharactersMax"; - - static final String UNSUPPORTED_EVENT_TYPE = "NUMBER_ORDER_PROCESSING"; - SinchEventsService service; Map triggerToURL = @@ -55,10 +52,6 @@ public void serviceAvailable() { @When("I send a request to trigger the {string} for {string} event") public void triggerEvent(String status, String trigger) throws IOException { - if (UNSUPPORTED_EVENT_TYPE.equals(trigger)) { - return; - } - WebhooksHelper.Response response = WebhooksHelper.callURL( new URL(triggerToURL.get(status + "_" + trigger)), service::parseEvent); @@ -68,10 +61,6 @@ public void triggerEvent(String status, String trigger) throws IOException { @Then("the header of the {string} for {string} event contains a valid signature") public void validateEventSignature(String status, String trigger) { - if (UNSUPPORTED_EVENT_TYPE.equals(trigger)) { - return; - } - WebhooksHelper.Response receivedEvent = receivedEvents.get(status + "_" + trigger); @@ -84,10 +73,6 @@ public void validateEventSignature(String status, String trigger) { @Then("the event describes a {string} for {string} event") public void validateResult(String status, String trigger) { - if (UNSUPPORTED_EVENT_TYPE.equals(trigger)) { - return; - } - NumberSinchEvent expectedSuccess = NumberSinchEvent.builder() .setEventId("01j1wefx7p3wf2r3x6h4dh6hh9") From cc89224a5026d0bdda7f565cc0142c98d5aef53c Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Mon, 17 Aug 2026 10:50:24 +0200 Subject: [PATCH 09/21] Comments addressed part 2 --- CHANGELOG.md | 1 - .../src/main/com/sinch/sdk/auth/adapters/OAuthManager.java | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c657b5f36..7e795c6ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,6 @@ All notable changes to the **Sinch Java SDK** are documented in this file. - **[feature]** Default Apache HttpClient retry policy disabled in favor of a dedicated SDK implementation: - honor the `Retry-After` response header on `HTTP 429` and fallback to exponential backoff if not present - max retry changed from default Apache HttpClient (`1`) to SDK implementation: `3` -- **[feature]** `DateUtil`: new `RFC7231StringToInstant` accepting the three HTTP-date formats of RFC 7231 (IMF-fixdate, RFC 850, asctime) ### Conversation - **[feature]** [Events] Support `ReadMessageEvent` app event (WhatsApp only): use `ReadMessageEvent.READ_MESSAGE_EVENT` diff --git a/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java b/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java index 3af8bb466..c1d46da37 100644 --- a/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java +++ b/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java @@ -15,6 +15,7 @@ import com.sinch.sdk.core.utils.Pair; import com.sinch.sdk.core.utils.StringUtil; import com.sinch.sdk.models.UnifiedCredentials; +import java.time.DateTimeException; import java.time.Duration; import java.time.Instant; import java.util.AbstractMap; @@ -226,7 +227,9 @@ private static Optional parseRetryAfterMillis(String value) { } try { return Optional.of(Math.max(0, Duration.between(Instant.now(), retryAt).toMillis())); - } catch (ArithmeticException tooFarInTheFuture) { + } catch (DateTimeException | ArithmeticException unusableDate) { + // Never let a bad date reach the caller: this only computes a backoff, so anything we + // cannot turn into a delay falls back to the exponential one rather than failing the call. return Optional.empty(); } } From 1975c2d9f2f8f246b76f5b1f64fbeace9d634a0b Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Wed, 19 Aug 2026 16:26:15 +0200 Subject: [PATCH 10/21] DEVEXP-1581: Sprint 13 OAS Syncho, sources generated --- .../models/v1/ConversationChannel.java | 44 +++++++++---------- .../v1/messages/types/choice/DisplayMode.java | 6 ++- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/ConversationChannel.java b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/ConversationChannel.java index 583064451..ea5e41669 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/ConversationChannel.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/conversation/models/v1/ConversationChannel.java @@ -8,42 +8,42 @@ /** The identifier of the channel you want to include. Must be one of the enum values. */ public class ConversationChannel extends EnumDynamic { - /** The WhatsApp channel. */ - public static final ConversationChannel WHATSAPP = new ConversationChannel("WHATSAPP"); - - /** The RCS channel. */ - public static final ConversationChannel RCS = new ConversationChannel("RCS"); - /** The SMS channel. */ public static final ConversationChannel SMS = new ConversationChannel("SMS"); - /** The Facebook Messenger channel. */ - public static final ConversationChannel MESSENGER = new ConversationChannel("MESSENGER"); + /** The RCS channel. */ + public static final ConversationChannel RCS = new ConversationChannel("RCS"); - /** The Viber Business Messages channel. */ - public static final ConversationChannel VIBERBM = new ConversationChannel("VIBERBM"); + /** The WhatsApp channel. */ + public static final ConversationChannel WHATSAPP = new ConversationChannel("WHATSAPP"); /** The MMS channel. */ public static final ConversationChannel MMS = new ConversationChannel("MMS"); - /** The Instagram channel. */ - public static final ConversationChannel INSTAGRAM = new ConversationChannel("INSTAGRAM"); - - /** The Telegram channel. */ - public static final ConversationChannel TELEGRAM = new ConversationChannel("TELEGRAM"); - /** The KakaoTalk channel. */ public static final ConversationChannel KAKAOTALK = new ConversationChannel("KAKAOTALK"); /** The KakaoTalk chat channel (used primarily in ConsultationTalk). */ public static final ConversationChannel KAKAOTALKCHAT = new ConversationChannel("KAKAOTALKCHAT"); + /** The Viber Business Messages channel. */ + public static final ConversationChannel VIBERBM = new ConversationChannel("VIBERBM"); + /** The LINE channel. */ public static final ConversationChannel LINE = new ConversationChannel("LINE"); + /** The Instagram channel. */ + public static final ConversationChannel INSTAGRAM = new ConversationChannel("INSTAGRAM"); + + /** The Facebook Messenger channel. */ + public static final ConversationChannel MESSENGER = new ConversationChannel("MESSENGER"); + /** The WeChat channel. */ public static final ConversationChannel WECHAT = new ConversationChannel("WECHAT"); + /** The Telegram channel. */ + public static final ConversationChannel TELEGRAM = new ConversationChannel("TELEGRAM"); + /** The Apple Messages for Business channel. */ public static final ConversationChannel APPLEBC = new ConversationChannel("APPLEBC"); @@ -52,18 +52,18 @@ public class ConversationChannel extends EnumDynamic { /** Default. Transient — choice disappears when new messages arrive. */ From 421e9a70132c0a2d5ba431fb3b3ac5a3ef58d7f7 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 21 Aug 2026 11:48:06 +0200 Subject: [PATCH 11/21] DEVEXP-1592: Run E2E tests against the remote mock server, temporary CI workaround --- .github/workflows/build.yml | 35 ++++++++---------- .../test/java/com/sinch/sdk/e2e/Config.java | 37 +++++++++++++------ .../v1/SinchEventsEventsSteps.java | 3 +- pom.xml | 2 + 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3fd82c107..bf8249368 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,21 +7,24 @@ jobs: name: Build Java ${{ matrix.java-version }} runs-on: ubuntu-latest + timeout-minutes: 10 strategy: matrix: - java-version: ['8', '11', '17', '21', '25'] + # DEVEXP-1592 (temporary workaround): only Java 21 is built for now. + # TODO: restore ['8', '11', '17', '21', '25'] along with the commented 'include' entries below. + java-version: ['21'] include: - - java-version: '8' - maven-args: '-Dmaven.test.skip=true -Dspotless.check.skip=true clean package' - - java-version: '11' - maven-args: '-Dspotless.check.skip=true clean package javadoc:javadoc' - - java-version: '17' - maven-args: 'clean package javadoc:javadoc' + # - java-version: '8' + # maven-args: '-Dmaven.test.skip=true -Dspotless.check.skip=true clean package' + # - java-version: '11' + # maven-args: '-Dspotless.check.skip=true clean package javadoc:javadoc' + # - java-version: '17' + # maven-args: 'clean package javadoc:javadoc' - java-version: '21' maven-args: 'clean package javadoc:javadoc' - - java-version: '25' - maven-args: 'clean package javadoc:javadoc' + # - java-version: '25' + # maven-args: 'clean package javadoc:javadoc' steps: - uses: actions/checkout@v3 - name: Set up JDK ${{ matrix.java-version }} @@ -42,6 +45,10 @@ jobs: needs: build runs-on: ubuntu-latest + # DEVEXP-1592 (temporary workaround): E2E tests are disabled for every Java version. + # TODO: remove this 'if' to re-enable them. + if: false + strategy: matrix: java-version: ['8', '11', '17', '21', '25'] @@ -61,16 +68,6 @@ jobs: fetch-depth: 0 path: sinch-sdk-mockserver - - name: Install Docker Compose - run: | - sudo apt-get update - sudo apt-get install -y docker-compose - - - name: Start mock servers with Docker Compose - run: | - cd sinch-sdk-mockserver - docker-compose up -d - - name: Link to feature files run: | ln -s ${{ github.workspace }}/sinch-sdk-mockserver/features client/src/test/resources diff --git a/client/src/test/java/com/sinch/sdk/e2e/Config.java b/client/src/test/java/com/sinch/sdk/e2e/Config.java index ec636f6d4..80d60568c 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/Config.java +++ b/client/src/test/java/com/sinch/sdk/e2e/Config.java @@ -17,26 +17,32 @@ public class Config { public static final String PROJECT_ID = "tinyfrog-jump-high-over-lilypadbasin"; public static final String KEY_ID = "'keyId"; public static final String KEY_SECRET = "keySecret"; - public static final String AUTH_URL = "http://localhost:3011/oauth2/token"; - public static final String NUMBERS_HOST_NAME = "http://localhost:3013"; - public static final String CONVERSATION_HOST_NAME = "http://localhost:3014"; - public static final String CONVERSATION_TEMPLATE_HOST_NAME = "http://localhost:3015"; + private static final String DEFAULT_MOCK_SERVER_URL = "https://sinch-sdk-mockserver.sliplane.app"; + + public static final String MOCK_SERVER_URL = resolveMockServerUrl(); + public static final String AUTH_URL = MOCK_SERVER_URL + "/authentication/oauth2/token"; + public static final String NUMBERS_HOST_NAME = MOCK_SERVER_URL + "/numbers"; + public static final String CONVERSATION_HOST_NAME = MOCK_SERVER_URL + "/conversation"; + public static final String CONVERSATION_TEMPLATE_HOST_NAME = + MOCK_SERVER_URL + "/conversation-templates"; public static final ConversationRegion CONVERSATION_REGION = ConversationRegion.US; public static final String APPLICATION_KEY = "appKey"; public static final String APPLICATION_SECRET = "YXBwU2VjcmV0"; - public static final String VOICE_HOST_NAME = "http://localhost:3019"; - public static final String VOICE_MANAGEMENT_HOST_NAME = "http://localhost:3020"; + public static final String VOICE_HOST_NAME = MOCK_SERVER_URL + "/voice"; + public static final String VOICE_MANAGEMENT_HOST_NAME = + MOCK_SERVER_URL + "/voice-application-management"; - public static final String MAILGUN_HOST_NAME = "http://localhost:3021"; + public static final String MAILGUN_HOST_NAME = MOCK_SERVER_URL + "/mailgun"; public static final String MAILGUN_API_KEY = "apiKey"; - public static final String MAILGUN_STORAGE = "http://localhost:3021"; + public static final String MAILGUN_STORAGE = MOCK_SERVER_URL + "/mailgun"; - public static final String SMS_HOST_NAME = "http://localhost:3017"; + public static final String SMS_HOST_NAME = MOCK_SERVER_URL + "/sms"; - public static final String VERIFICATION_HOST_NAME = "http://localhost:3018"; + public static final String VERIFICATION_HOST_NAME = MOCK_SERVER_URL; + public static final String VERIFICATION_WEBHOOKS_HOST_NAME = MOCK_SERVER_URL + "/verification"; - public static final String NUMBER_LOOKUP_HOST_NAME = "http://localhost:3022"; + public static final String NUMBER_LOOKUP_HOST_NAME = MOCK_SERVER_URL + "/number-lookup"; public static final int PROXY_UNAUTHENTICATED_PORT = 3128; public static final int PROXY_AUTHENTICATED_PORT = 3129; @@ -134,6 +140,15 @@ public static SinchClient getSinchClientProxyAuthenticated() { return LazyHolder.INSTANCE.clientProxyAuthenticated; } + private static String resolveMockServerUrl() { + String url = System.getenv("SINCH_MOCKSERVER_BASE_URL"); + if (null == url || url.trim().isEmpty()) { + return DEFAULT_MOCK_SERVER_URL; + } + url = url.trim(); + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + private static Configuration createConfigurationWithProxyUsage(HttpProxyConfiguration proxy) { return Configuration.builder() .setOAuthUrl(PROXY_VISIBLE_AUTH_URL) diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/verification/v1/SinchEventsEventsSteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/verification/v1/SinchEventsEventsSteps.java index 0d4c2b12e..93891d7de 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/domains/verification/v1/SinchEventsEventsSteps.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/verification/v1/SinchEventsEventsSteps.java @@ -27,7 +27,8 @@ public class SinchEventsEventsSteps { - static final String WEBHOOKS_PATH = Config.VERIFICATION_HOST_NAME + "/webhooks/verification/"; + static final String WEBHOOKS_PATH = + Config.VERIFICATION_WEBHOOKS_HOST_NAME + "/webhooks/verification/"; SinchEventsService service; diff --git a/pom.xml b/pom.xml index aceee3e10..1610974c3 100644 --- a/pom.xml +++ b/pom.xml @@ -297,8 +297,10 @@ com.sinch.sdk.e2e.domains.voice.v1.VoiceIT com.sinch.sdk.e2e.domains.verification.v1.VerificationIT com.sinch.sdk.e2e.domains.numberlookup.v2.NumberLookupIT + From 2f78172b3cbb591e1696c45d9ca86e25345244af Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Tue, 25 Aug 2026 14:37:16 +0200 Subject: [PATCH 12/21] DEVEXP-1484: Support HTTP-429 retry policy for all endpoints (#44) * DEVEXP-1484: Support HTTP429 retry policy for all endpoints --- CHANGELOG.md | 3 + README.md | 49 ++ .../src/main/com/sinch/sdk/SinchClient.java | 15 +- .../sinch/sdk/auth/adapters/OAuthManager.java | 116 +---- .../sinch/sdk/http/DefaultRetryManager.java | 183 ++++++++ .../com/sinch/sdk/http/HttpClientApache.java | 59 ++- .../main/com/sinch/sdk/http/RetryCapable.java | 25 + .../main/com/sinch/sdk/http/RetryManager.java | 42 ++ .../com/sinch/sdk/models/Configuration.java | 29 +- .../sinch/sdk/models/RetryConfiguration.java | 193 ++++++++ .../com/sinch/sdk/models/RetryPolicy.java | 23 + .../com/sinch/sdk/SinchClientRetryTest.java | 187 ++++++++ .../java/com/sinch/sdk/SinchClientTest.java | 41 ++ .../sdk/auth/adapters/OAuthManagerTest.java | 192 +++----- .../HttpClientApacheNoTransportRetryTest.java | 76 +++ .../http/HttpClientApacheRetryAuthTest.java | 83 ++++ .../sinch/sdk/http/HttpClientApacheTest.java | 22 + .../com/sinch/sdk/http/RetryManagerTest.java | 444 ++++++++++++++++++ .../sdk/models/RetryConfigurationTest.java | 173 +++++++ 19 files changed, 1713 insertions(+), 242 deletions(-) create mode 100644 client/src/main/com/sinch/sdk/http/DefaultRetryManager.java create mode 100644 client/src/main/com/sinch/sdk/http/RetryCapable.java create mode 100644 client/src/main/com/sinch/sdk/http/RetryManager.java create mode 100644 client/src/main/com/sinch/sdk/models/RetryConfiguration.java create mode 100644 client/src/main/com/sinch/sdk/models/RetryPolicy.java create mode 100644 client/src/test/java/com/sinch/sdk/SinchClientRetryTest.java create mode 100644 client/src/test/java/com/sinch/sdk/http/HttpClientApacheNoTransportRetryTest.java create mode 100644 client/src/test/java/com/sinch/sdk/http/HttpClientApacheRetryAuthTest.java create mode 100644 client/src/test/java/com/sinch/sdk/http/RetryManagerTest.java create mode 100644 client/src/test/java/com/sinch/sdk/models/RetryConfigurationTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c432d4cf7..9d3e6c665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ All notable changes to the **Sinch Java SDK** are documented in this file. - **[feature]** Default Apache HttpClient retry policy disabled in favor of a dedicated SDK implementation: - honor the `Retry-After` response header on `HTTP 429` and fallback to exponential backoff if not present - max retry changed from default Apache HttpClient (`1`) to SDK implementation: `3` +- **[feature]** `HTTP 429` retries extended to all endpoints (previously OAuth only), handled by a dedicated `RetryManager` +- **[feature]** Retry policy configurable from `SinchClient` via `setRetryConfiguration`: `retryPolicy` (`DEFAULT`, `RETRY_AFTER`, `BACKOFF`, `NONE`), `maxRetryCount` (default `3`), `exponentialBackoff` (default `4`) +- **[fix]** `HttpClientApache`: preserve the status code of an `ApiException` raised during authentication instead of discarding it ### Conversation - **[feature]** [Events] Support `ReadMessageEvent` app event (WhatsApp only): use `ReadMessageEvent.READ_MESSAGE_EVENT` diff --git a/README.md b/README.md index ce2ba5d8b..f32f25d29 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ To use Sinch services, you'll need a Sinch account and access keys. You can sign - [Logging](#logging) - [Handling Exceptions](#handling-exceptions) - [Proxy configuration](#proxy-configuration) +- [Retry configuration](#retry-configuration) - [Third-party dependencies](#third-party-dependencies) - [Examples](#examples) - [Changelog and Migration](#changelog--migration) @@ -456,6 +457,54 @@ SinchClient client = new SinchClient(configuration); ``` +## Retry configuration + +When an API call or OAuth token request returns HTTP 429 (Too Many Requests), the SDK retries automatically. Configure this on `SinchClient`; the same settings apply to product API calls and token fetches. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `retryPolicy` | `RetryPolicy` | `DEFAULT` | `DEFAULT`: honor `Retry-After` when present, otherwise exponential backoff. `RETRY_AFTER`: retry only when a usable `Retry-After` header is present. `BACKOFF`: ignore `Retry-After` and use full-jitter exponential backoff. `NONE`: disable automatic retries. | +| `maxRetryCount` | `number` | `3` | Maximum retries after the first attempt before the error is surfaced to the caller. Must be a non-negative integer (`0` is allowed; decimals are rejected). | +| `exponentialBackoff` | `number` | `4` | Growth factor for the backoff ceiling (`1000ms * exponentialBackoff^attempt`). The wait is a random value between 0 and that ceiling. Must be a positive number (`> 0`; decimals are allowed). | + +`Retry-After` may be a delay in seconds or an HTTP-date (RFC 7231). A small jitter (0–250 ms) is added so concurrent clients do not retry in lockstep. Invalid values are rejected. + +### Retry settings + +```java +import com.sinch.sdk.SinchClient; +import com.sinch.sdk.models.Configuration; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; + +... +Configuration configuration = Configuration.builder() + ... + .setRetryConfiguration( + RetryConfiguration.builder() + .setRetryPolicy(RetryPolicy.BACKOFF) + .setMaxRetryCount(5) + .setExponentialBackoff(2) + .build()) + .build(); +SinchClient client = new SinchClient(configuration); +``` + +### Disable Retry Policy + +To disable automatic retries (for example when an outer HTTP layer already honors `Retry-After`): + +```java +Configuration configuration = Configuration.builder() + ... + .setRetryConfiguration( + RetryConfiguration.builder() + .setRetryPolicy(RetryPolicy.NONE) + .build()) + .build(); +SinchClient client = new SinchClient(configuration); +``` + ## Third-party dependencies The SDK relies on the following third-party dependencies: - [Jackson](https://github.com/FasterXML/jackson-jakarta-rs-providers): provides JSON serialization/deserialization functionality. diff --git a/client/src/main/com/sinch/sdk/SinchClient.java b/client/src/main/com/sinch/sdk/SinchClient.java index 36a6d6b1b..a4e392f5c 100644 --- a/client/src/main/com/sinch/sdk/SinchClient.java +++ b/client/src/main/com/sinch/sdk/SinchClient.java @@ -1,5 +1,6 @@ package com.sinch.sdk; +import com.sinch.sdk.core.http.HttpClient; import com.sinch.sdk.core.utils.StringUtil; import com.sinch.sdk.domains.conversation.ConversationService; import com.sinch.sdk.domains.numberlookup.NumberLookupService; @@ -7,6 +8,7 @@ import com.sinch.sdk.domains.sms.SMSService; import com.sinch.sdk.domains.verification.VerificationService; import com.sinch.sdk.domains.voice.VoiceService; +import com.sinch.sdk.http.DefaultRetryManager; import com.sinch.sdk.http.HttpClientApache; import com.sinch.sdk.models.Configuration; import com.sinch.sdk.models.ConversationContext; @@ -68,7 +70,7 @@ public class SinchClient { private volatile VoiceService voice; private volatile ConversationService conversation; private volatile NumberLookupService lookup; - private volatile HttpClientApache httpClient; + private volatile HttpClient httpClient; /** * Create a Sinch Client instance based onto configuration @@ -443,13 +445,16 @@ private Properties handlePropertiesFile(String fileName) { return prop; } - private HttpClientApache getHttpClient() { - HttpClientApache local = httpClient; + private HttpClient getHttpClient() { + HttpClient local = httpClient; if (null == local || local.isClosed()) { synchronized (this) { local = httpClient; if (null == local || local.isClosed()) { - local = new HttpClientApache(configuration.getHttpProxyConfiguration().orElse(null)); + local = + new HttpClientApache( + configuration.getHttpProxyConfiguration().orElse(null), + new DefaultRetryManager(configuration.getRetryConfiguration().orElse(null))); // set SDK User-Agent String userAgent = formatSdkUserAgentHeader(); @@ -507,7 +512,7 @@ String formatAuxiliaryFlag(String auxiliaryFlag) { */ public void close() { synchronized (this) { - HttpClientApache local = httpClient; + HttpClient local = httpClient; httpClient = null; numbers = null; sms = null; diff --git a/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java b/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java index c1d46da37..e5c7a8e3c 100644 --- a/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java +++ b/client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.sinch.sdk.auth.models.BearerAuthResponse; import com.sinch.sdk.core.exceptions.ApiAuthException; +import com.sinch.sdk.core.exceptions.ApiException; import com.sinch.sdk.core.http.AuthManager; import com.sinch.sdk.core.http.HttpClient; import com.sinch.sdk.core.http.HttpMapper; @@ -11,19 +12,15 @@ import com.sinch.sdk.core.http.HttpResponse; import com.sinch.sdk.core.http.HttpStatus; import com.sinch.sdk.core.models.ServerConfiguration; -import com.sinch.sdk.core.utils.DateUtil; import com.sinch.sdk.core.utils.Pair; import com.sinch.sdk.core.utils.StringUtil; +import com.sinch.sdk.http.RetryCapable; +import com.sinch.sdk.http.RetryManager; import com.sinch.sdk.models.UnifiedCredentials; -import java.time.DateTimeException; -import java.time.Duration; -import java.time.Instant; import java.util.AbstractMap; import java.util.Collection; import java.util.Collections; import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ThreadLocalRandom; import java.util.function.Supplier; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -36,15 +33,6 @@ public class OAuthManager implements AuthManager { private static final Logger LOGGER = Logger.getLogger(OAuthManager.class.getName()); private static final String AUTH_KEYWORD = "Bearer"; - private static final double BACKOFF_BASE_SECONDS = 1.0; - private static final int BACKOFF_GROWTH = 4; - - private static final String RETRY_AFTER_HEADER = "Retry-After"; - - protected static final int MAX_RATE_LIMIT_RETRIES = 3; - - private static final long RETRY_AFTER_JITTER_MILLIS = 250L; - private final ServerConfiguration oAuthServer; private final HttpMapper mapper; private final Supplier httpClientSupplier; @@ -107,39 +95,14 @@ public Collection> getAuthorizationHeaders( new Pair<>("Authorization", AUTH_KEYWORD + " " + currentToken)); } - /** Fetches a token, retrying only while the authentication service reports a rate limit. */ private String getNewToken() { - - for (int attempt = 0; ; attempt++) { - HttpResponse response = callOAuthEndpoint(); - if (response.getCode() != HttpStatus.TOO_MANY_REQUESTS) { - return extractAccessToken(response); - } - if (attempt >= MAX_RATE_LIMIT_RETRIES) { - throw new ApiAuthException( - "Token refresh failed: rate limited by the authentication service (HTTP 429) after " - + (MAX_RATE_LIMIT_RETRIES + 1) - + " attempts"); - } - long sleepMillis = computeBackoffMillis(response, attempt); - LOGGER.fine( - "Rate limited (HTTP 429) during token refresh, attempt " - + (attempt + 1) - + "/" - + (MAX_RATE_LIMIT_RETRIES + 1) - + ", waiting " - + sleepMillis - + "ms before retrying"); - try { - Thread.sleep(sleepMillis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ApiAuthException("Token refresh interrupted"); - } - } + return extractAccessToken(callOAuthEndpoint()); } - /** Performs the OAuth request once. A transport failure or a missing response is final. */ + /** + * Performs the OAuth request under the retry policy. A transport failure or a missing response is + * final; a rate-limited one is retried for as long as the policy allows. + */ private HttpResponse callOAuthEndpoint() { LOGGER.fine("Calling OAuth endpoint"); @@ -153,9 +116,12 @@ private HttpResponse callOAuthEndpoint() { null, Collections.singletonList("application/x-www-form-urlencoded"), Collections.singletonList(SCHEMA_KEYWORD_BASIC)); + HttpClient httpClient = httpClientSupplier.get(); HttpResponse httpResponse; try { - httpResponse = httpClientSupplier.get().invokeAPI(oAuthServer, authManagers, request); + httpResponse = + retryManagerOf(httpClient) + .execute(() -> httpClient.invokeAPI(oAuthServer, authManagers, request)); } catch (Exception e) { throw new ApiAuthException( "OAuth request failed: network or client error: " + e.getMessage()); @@ -166,8 +132,22 @@ private HttpResponse callOAuthEndpoint() { return httpResponse; } + private static RetryManager retryManagerOf(HttpClient client) { + if (!(client instanceof RetryCapable)) { + return RetryManager.DEFAULTS; + } + return ((RetryCapable) client).getRetryManager().orElse(RetryManager.NO_RETRY); + } + private String extractAccessToken(HttpResponse response) { + // Reported with the status the server actually sent, not as an authentication failure: a + // caller branching on HTTP 429 must see the same code here as from any other endpoint. + if (response.getCode() == HttpStatus.TOO_MANY_REQUESTS) { + throw new ApiException( + HttpStatus.TOO_MANY_REQUESTS, + "Token refresh failed: rate limited by the authentication service (HTTP 429)"); + } if (!HttpStatus.isSuccessfulStatus(response.getCode())) { throw new ApiAuthException("Unable to extract token with HTTP " + response.getCode()); } @@ -190,50 +170,6 @@ private String extractAccessToken(HttpResponse response) { return accessToken; } - long computeBackoffMillis(HttpResponse response, int attempt) { - Optional retryAfter = - retryAfterHeader(response).flatMap(OAuthManager::parseRetryAfterMillis); - if (retryAfter.isPresent()) { - return retryAfter.get() + ThreadLocalRandom.current().nextLong(RETRY_AFTER_JITTER_MILLIS + 1); - } - double maxDelay = BACKOFF_BASE_SECONDS * Math.pow(BACKOFF_GROWTH, attempt); - return (long) (ThreadLocalRandom.current().nextDouble(maxDelay) * 1000); - } - - private static Optional retryAfterHeader(HttpResponse response) { - return response.getHeaders().entrySet().stream() - .filter(entry -> RETRY_AFTER_HEADER.equalsIgnoreCase(entry.getKey())) - .map(Map.Entry::getValue) - .filter(values -> null != values && !values.isEmpty()) - .map(values -> values.get(0)) - .findFirst(); - } - - private static Optional parseRetryAfterMillis(String value) { - String trimmed = null == value ? "" : value.trim(); - if (trimmed.isEmpty()) { - return Optional.empty(); - } - try { - double seconds = Double.parseDouble(trimmed); - boolean usable = seconds >= 0 && seconds < Long.MAX_VALUE / 1000d; - return usable ? Optional.of((long) (seconds * 1000)) : Optional.empty(); - } catch (NumberFormatException notDeltaSeconds) { - } - // Not a number, so try the HTTP-date form: all three RFC 7231 spellings are accepted. - Instant retryAt = DateUtil.RFC7231StringToInstant(trimmed); - if (null == retryAt) { - return Optional.empty(); - } - try { - return Optional.of(Math.max(0, Duration.between(Instant.now(), retryAt).toMillis())); - } catch (DateTimeException | ArithmeticException unusableDate) { - // Never let a bad date reach the caller: this only computes a backoff, so anything we - // cannot turn into a delay falls back to the exponential one rather than failing the call. - return Optional.empty(); - } - } - public boolean validateAuthenticatedRequest( String method, String path, Map headers, String jsonPayload) { LOGGER.severe("checkAuthentication not implemented"); diff --git a/client/src/main/com/sinch/sdk/http/DefaultRetryManager.java b/client/src/main/com/sinch/sdk/http/DefaultRetryManager.java new file mode 100644 index 000000000..7b60999bf --- /dev/null +++ b/client/src/main/com/sinch/sdk/http/DefaultRetryManager.java @@ -0,0 +1,183 @@ +package com.sinch.sdk.http; + +import com.sinch.sdk.core.exceptions.ApiException; +import com.sinch.sdk.core.http.HttpResponse; +import com.sinch.sdk.core.http.HttpStatus; +import com.sinch.sdk.core.utils.DateUtil; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.concurrent.Callable; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +/** + * Retry policy for rate-limited (HTTP 429) responses: when to retry, how long to wait, and when to + * stop. Only HTTP 429 is retried. Every other response — including {@code 5xx} — is handed straight + * back, as is the final 429 once the retry budget is spent, so the caller's error handling sees the + * response the server actually sent. + * + * @since 2.2 + */ +public class DefaultRetryManager implements RetryManager { + + private static final Logger LOGGER = Logger.getLogger(DefaultRetryManager.class.getName()); + + private static final String RETRY_AFTER_HEADER = "Retry-After"; + + private static final double BACKOFF_BASE_MILLIS = 1000d; + + private static final long RETRY_AFTER_JITTER_MILLIS = 250L; + + private static final double MAX_BACKOFF_MILLIS = TimeUnit.HOURS.toMillis(1); + + static final ThreadLocal LOOP_ACTIVE = new ThreadLocal<>(); + + private final RetryConfiguration configuration; + + /** + * @param configuration Policy to apply, or {@code null} for {@link RetryConfiguration#DEFAULTS} + * @since 2.2 + */ + public DefaultRetryManager(RetryConfiguration configuration) { + this.configuration = null != configuration ? configuration : RetryConfiguration.DEFAULTS; + } + + @Override + public RetryConfiguration getRetryConfiguration() { + return configuration; + } + + @Override + public HttpResponse execute(Callable call) throws Exception { + + if (Boolean.TRUE.equals(LOOP_ACTIVE.get())) { + return call.call(); + } + + LOOP_ACTIVE.set(Boolean.TRUE); + try { + return executeWithRetries(call); + } finally { + LOOP_ACTIVE.remove(); + } + } + + private HttpResponse executeWithRetries(Callable call) throws Exception { + + int maxRetryCount = configuration.getMaxRetryCount(); + + for (int attempt = 0; ; attempt++) { + + HttpResponse response = call.call(); + + if (null == response || response.getCode() != HttpStatus.TOO_MANY_REQUESTS) { + return response; + } + if (attempt >= maxRetryCount) { + LOGGER.fine( + "Rate limited (HTTP 429) and the retry budget of " + + maxRetryCount + + " is spent, returning the response to the caller"); + return response; + } + + OptionalLong delay = computeBackoffMillis(response, attempt); + if (!delay.isPresent()) { + return response; + } + + long sleepMillis = delay.getAsLong(); + LOGGER.fine( + "Rate limited (HTTP 429), attempt " + + (attempt + 1) + + "/" + + (maxRetryCount + 1) + + ", waiting " + + sleepMillis + + "ms before retrying"); + try { + Thread.sleep(sleepMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ApiException("Interrupted while waiting to retry a rate-limited request", e); + } + } + } + + /** + * Delay to wait before the next attempt, or empty when the configured policy declines to retry + * this response. + */ + OptionalLong computeBackoffMillis(HttpResponse response, int attempt) { + + RetryPolicy policy = configuration.getRetryPolicy(); + + if (RetryPolicy.NONE == policy) { + return OptionalLong.empty(); + } + + if (RetryPolicy.BACKOFF != policy) { + OptionalLong retryAfter = retryAfterMillis(response); + if (retryAfter.isPresent()) { + return OptionalLong.of( + retryAfter.getAsLong() + + ThreadLocalRandom.current().nextLong(RETRY_AFTER_JITTER_MILLIS + 1)); + } + if (RetryPolicy.RETRY_AFTER == policy) { + return OptionalLong.empty(); + } + } + + double maxDelayMillis = + Math.min( + BACKOFF_BASE_MILLIS * Math.pow(configuration.getExponentialBackoff(), attempt), + MAX_BACKOFF_MILLIS); + return OptionalLong.of((long) ThreadLocalRandom.current().nextDouble(maxDelayMillis)); + } + + private static OptionalLong retryAfterMillis(HttpResponse response) { + return retryAfterHeader(response) + .map(DefaultRetryManager::parseRetryAfterMillis) + .orElse(OptionalLong.empty()); + } + + private static Optional retryAfterHeader(HttpResponse response) { + return response.getHeaders().entrySet().stream() + .filter(entry -> RETRY_AFTER_HEADER.equalsIgnoreCase(entry.getKey())) + .map(Map.Entry::getValue) + .filter(values -> null != values && !values.isEmpty()) + .map(values -> values.get(0)) + .findFirst(); + } + + private static OptionalLong parseRetryAfterMillis(String value) { + + String trimmed = null == value ? "" : value.trim(); + if (trimmed.isEmpty()) { + return OptionalLong.empty(); + } + + try { + double seconds = Double.parseDouble(trimmed); + boolean usable = seconds >= 0 && seconds < Long.MAX_VALUE / 1000d; + return usable ? OptionalLong.of((long) (seconds * 1000)) : OptionalLong.empty(); + } catch (NumberFormatException notDeltaSeconds) { + } + + Instant retryAt = DateUtil.RFC7231StringToInstant(trimmed); + if (null == retryAt) { + return OptionalLong.empty(); + } + try { + return OptionalLong.of(Math.max(0, Duration.between(Instant.now(), retryAt).toMillis())); + } catch (ArithmeticException tooFarInTheFuture) { + return OptionalLong.empty(); + } + } +} diff --git a/client/src/main/com/sinch/sdk/http/HttpClientApache.java b/client/src/main/com/sinch/sdk/http/HttpClientApache.java index fe69826e4..bc3d7dc73 100644 --- a/client/src/main/com/sinch/sdk/http/HttpClientApache.java +++ b/client/src/main/com/sinch/sdk/http/HttpClientApache.java @@ -30,6 +30,7 @@ import java.util.Map; import java.util.Optional; import java.util.Scanner; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Logger; import java.util.stream.Collectors; import org.apache.hc.client5.http.ClientProtocolException; @@ -52,7 +53,7 @@ import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; import org.apache.hc.core5.http.support.AbstractMessageBuilder; -public class HttpClientApache implements com.sinch.sdk.core.http.HttpClient { +public class HttpClientApache implements com.sinch.sdk.core.http.HttpClient, RetryCapable { private static final Logger LOGGER = Logger.getLogger(HttpClientApache.class.getName()); @@ -66,12 +67,29 @@ public class HttpClientApache implements com.sinch.sdk.core.http.HttpClient { private volatile CloseableHttpClient client; + private final RetryManager retryManager; + public HttpClientApache() { this(null); } public HttpClientApache(HttpProxyConfiguration proxyConfiguration) { + this(proxyConfiguration, null); + } + + /** + * @param proxyConfiguration Proxy to route through, or {@code null} for a direct connection + * @param retryManager Manager applied to rate-limited responses, or {@code null} to retry nothing + * @since 2.2 + */ + public HttpClientApache(HttpProxyConfiguration proxyConfiguration, RetryManager retryManager) { this.client = buildHttpClient(proxyConfiguration); + this.retryManager = null != retryManager ? retryManager : RetryManager.NO_RETRY; + } + + @Override + public Optional getRetryManager() { + return Optional.ofNullable(retryManager); } private static CloseableHttpClient buildHttpClient(HttpProxyConfiguration proxyConfiguration) { @@ -196,11 +214,8 @@ public HttpResponse invokeAPI( addFormParams(requestBuilder, contentType, formParams); - addAuth(requestBuilder, authManagersByOasSecuritySchemes, authNames, body); - - ClassicHttpRequest request = requestBuilder.build(); - - HttpResponse response = processRequest(activeClient, request); + HttpResponse response = + send(activeClient, requestBuilder, authManagersByOasSecuritySchemes, authNames, body); LOGGER.finest("connection response: " + response); // HTTP 407 (Proxy Authentication Required) is normally handled transparently by Apache @@ -222,10 +237,8 @@ public HttpResponse invokeAPI( boolean couldRetryRequest = processUnauthorizedResponse(httpRequest, response, authManagersByOasSecuritySchemes); if (couldRetryRequest) { - // refresh authorization - addAuth(requestBuilder, authManagersByOasSecuritySchemes, authNames, body); - request = requestBuilder.build(); - response = processRequest(activeClient, request); + response = + send(activeClient, requestBuilder, authManagersByOasSecuritySchemes, authNames, body); LOGGER.finest("connection response on retry: " + response); } } @@ -239,6 +252,9 @@ public HttpResponse invokeAPI( } LOGGER.severe("HTTP protocol error: " + cpe.getMessage()); throw new ApiException("HTTP protocol error: " + cpe.getMessage(), cpe); + } catch (ApiException e) { + LOGGER.severe("Error:" + e); + throw e; } catch (Exception e) { LOGGER.severe("Error:" + e); throw new ApiException(e); @@ -414,6 +430,29 @@ private void addAuth( } } + /** + * Performs one HTTP exchange under the retry policy, so that a rate-limited response is retried + * for every endpoint that goes through this transport. + */ + private HttpResponse send( + CloseableHttpClient client, + ClassicRequestBuilder requestBuilder, + Map authManagersByOasSecuritySchemes, + Collection authNames, + String body) + throws Exception { + addAuth(requestBuilder, authManagersByOasSecuritySchemes, authNames, body); + + AtomicBoolean firstAttempt = new AtomicBoolean(true); + return retryManager.execute( + () -> { + if (!firstAttempt.getAndSet(false)) { + addAuth(requestBuilder, authManagersByOasSecuritySchemes, authNames, body); + } + return processRequest(client, requestBuilder.build()); + }); + } + HttpResponse processRequest(CloseableHttpClient client, ClassicHttpRequest request) throws IOException { return client.execute(request, HttpClientApache::processResponse); diff --git a/client/src/main/com/sinch/sdk/http/RetryCapable.java b/client/src/main/com/sinch/sdk/http/RetryCapable.java new file mode 100644 index 000000000..e3517d7ad --- /dev/null +++ b/client/src/main/com/sinch/sdk/http/RetryCapable.java @@ -0,0 +1,25 @@ +package com.sinch.sdk.http; + +import com.sinch.sdk.core.http.HttpClient; +import java.util.Optional; + +/** + * Implemented by an {@link HttpClient} that knows which retry policy it is working under, so that + * anything sending requests through it can follow that same policy rather than quietly falling back + * to the defaults. {@link com.sinch.sdk.auth.adapters.OAuthManager} relies on this to retry the + * token exchange under whatever was configured on the client, and to share that budget rather than + * opening a second one of its own. + * + * @since 2.2 + */ +public interface RetryCapable { + + /** + * Retry policy this client applies. + * + * @return Manager in use, or empty when the client deliberately carries none and nothing is to be + * retried + * @since 2.2 + */ + Optional getRetryManager(); +} diff --git a/client/src/main/com/sinch/sdk/http/RetryManager.java b/client/src/main/com/sinch/sdk/http/RetryManager.java new file mode 100644 index 000000000..5390bfad3 --- /dev/null +++ b/client/src/main/com/sinch/sdk/http/RetryManager.java @@ -0,0 +1,42 @@ +package com.sinch.sdk.http; + +import com.sinch.sdk.core.http.HttpResponse; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; +import java.util.concurrent.Callable; + +/** + * Applies a retry policy to a request. {@link DefaultRetryManager} is the implementation the SDK + * ships with; an HTTP client supplied by the caller can provide its own if implementing the {@link + * RetryCapable} interface. + * + * @since 2.2 + */ +public interface RetryManager { + + /** Applied to a client that carries no manager of its own. */ + RetryManager DEFAULTS = new DefaultRetryManager(RetryConfiguration.DEFAULTS); + + /** Applied to a client that deliberately carries none, so nothing is retried. */ + RetryManager NO_RETRY = + new DefaultRetryManager( + RetryConfiguration.builder().setRetryPolicy(RetryPolicy.NONE).build()); + + /** + * Retry policy in use. + * + * @return Configuration applied to rate-limited responses + * @since 2.2 + */ + RetryConfiguration getRetryConfiguration(); + + /** + * Performs the request, retrying a rate-limited response for as long as the policy allows. + * + * @param call The request to perform + * @return The response of the last attempt + * @throws Exception Whatever the request itself throws + * @since 2.2 + */ + HttpResponse execute(Callable call) throws Exception; +} diff --git a/client/src/main/com/sinch/sdk/models/Configuration.java b/client/src/main/com/sinch/sdk/models/Configuration.java index 05d26aef1..e7043e2c1 100644 --- a/client/src/main/com/sinch/sdk/models/Configuration.java +++ b/client/src/main/com/sinch/sdk/models/Configuration.java @@ -17,6 +17,7 @@ public class Configuration { private final ConversationContext conversationContext; private final HttpProxyConfiguration httpProxyConfiguration; private final NumberLookupContext numberLookupContext; + private final RetryConfiguration retryConfiguration; private Configuration( UnifiedCredentials unifiedCredentials, @@ -29,7 +30,8 @@ private Configuration( VerificationContext verificationContext, VoiceContext voiceContext, ConversationContext conversationContext, - NumberLookupContext numberLookupContext) { + NumberLookupContext numberLookupContext, + RetryConfiguration retryConfiguration) { this.unifiedCredentials = unifiedCredentials; this.applicationCredentials = applicationCredentials; this.smsServicePlanCredentials = smsServicePlanCredentials; @@ -41,6 +43,7 @@ private Configuration( this.conversationContext = conversationContext; this.numberLookupContext = numberLookupContext; this.httpProxyConfiguration = httpProxyConfiguration; + this.retryConfiguration = retryConfiguration; } @Override @@ -63,6 +66,8 @@ public String toString() { + numberLookupContext + ", httpProxyConfiguration=" + httpProxyConfiguration + + ", retryConfiguration=" + + retryConfiguration + "}"; } @@ -190,6 +195,17 @@ public Optional getHttpProxyConfiguration() { return Optional.ofNullable(httpProxyConfiguration); } + /** + * Get the retry policy applied to rate-limited responses + * + * @return Retry configuration, or empty when none was set and {@link RetryConfiguration#DEFAULTS} + * apply + * @since 2.2 + */ + public Optional getRetryConfiguration() { + return Optional.ofNullable(retryConfiguration); + } + /** * Getting Builder * @@ -229,6 +245,7 @@ public static class Builder { ConversationContext.Builder conversationContext; NumberLookupContext.Builder numberLookupContext; HttpProxyConfiguration httpProxyConfiguration; + RetryConfiguration retryConfiguration; protected Builder() {} @@ -263,6 +280,7 @@ protected Builder(Configuration configuration) { this.numberLookupContext = configuration.getNumberLookupContext().map(NumberLookupContext::builder).orElse(null); this.httpProxyConfiguration = configuration.getHttpProxyConfiguration().orElse(null); + this.retryConfiguration = configuration.getRetryConfiguration().orElse(null); } /** @@ -561,6 +579,12 @@ public Builder setHttpProxyConfiguration(HttpProxyConfiguration httpProxyConfigu return this; } + /** Set the retry policy applied to rate-limited responses */ + public Builder setRetryConfiguration(RetryConfiguration retryConfiguration) { + this.retryConfiguration = retryConfiguration; + return this; + } + /** * Build a Configuration instance from builder current state * @@ -580,7 +604,8 @@ public Configuration build() { null != verificationContext ? verificationContext.build() : null, null != voiceContext ? voiceContext.build() : null, null != conversationContext ? conversationContext.build() : null, - null != numberLookupContext ? numberLookupContext.build() : null); + null != numberLookupContext ? numberLookupContext.build() : null, + retryConfiguration); } } } diff --git a/client/src/main/com/sinch/sdk/models/RetryConfiguration.java b/client/src/main/com/sinch/sdk/models/RetryConfiguration.java new file mode 100644 index 000000000..0a86ba904 --- /dev/null +++ b/client/src/main/com/sinch/sdk/models/RetryConfiguration.java @@ -0,0 +1,193 @@ +package com.sinch.sdk.models; + +/** + * Retry policy applied to rate-limited responses: which delay to use, how many times to retry, and + * how fast the wait grows. + * + * @since 2.2 + */ +public class RetryConfiguration { + + /** Policy applied when none is configured. */ + public static final RetryPolicy DEFAULT_RETRY_POLICY = RetryPolicy.DEFAULT; + + /** Number of retries performed when none is configured. */ + public static final int DEFAULT_MAX_RETRY_COUNT = 3; + + /** Exponential backoff growth factor used when none is configured. */ + public static final int DEFAULT_EXPONENTIAL_BACKOFF = 4; + + /** + * Configuration holding the default of every field: {@link #DEFAULT_RETRY_POLICY}, {@link + * #DEFAULT_MAX_RETRY_COUNT} and {@link #DEFAULT_EXPONENTIAL_BACKOFF}. + * + *

Built through the builder rather than the constructor, so the defaults are subject to the + * same validation as any value a caller supplies. + * + * @since 2.2 + */ + public static final RetryConfiguration DEFAULTS = + builder() + .setRetryPolicy(DEFAULT_RETRY_POLICY) + .setMaxRetryCount(DEFAULT_MAX_RETRY_COUNT) + .setExponentialBackoff(DEFAULT_EXPONENTIAL_BACKOFF) + .build(); + + private final RetryPolicy retryPolicy; + private final int maxRetryCount; + private final int exponentialBackoff; + + private RetryConfiguration(RetryPolicy retryPolicy, int maxRetryCount, int exponentialBackoff) { + this.retryPolicy = retryPolicy; + this.maxRetryCount = maxRetryCount; + this.exponentialBackoff = exponentialBackoff; + } + + /** + * Strategy used to compute the delay before a retry. + * + * @return Retry policy + * @since 2.2 + */ + public RetryPolicy getRetryPolicy() { + return retryPolicy; + } + + /** + * Maximum number of retries performed on top of the initial attempt. Zero disables retries. + * + * @return Maximum retry count + * @since 2.2 + */ + public int getMaxRetryCount() { + return maxRetryCount; + } + + /** + * Growth factor of the exponential backoff, unused when the delay comes from a {@code + * Retry-After} header. + * + * @return Exponential backoff growth factor + * @since 2.2 + */ + public int getExponentialBackoff() { + return exponentialBackoff; + } + + @Override + public String toString() { + return "RetryConfiguration{" + + "retryPolicy=" + + retryPolicy + + ", maxRetryCount=" + + maxRetryCount + + ", exponentialBackoff=" + + exponentialBackoff + + '}'; + } + + /** + * Getting Builder + * + * @return New Builder instance + * @since 2.2 + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Getting Builder pre-populated from an existing instance + * + * @param configuration Source configuration to fill initial builder state + * @return New Builder instance + * @since 2.2 + */ + public static Builder builder(RetryConfiguration configuration) { + return new Builder(configuration); + } + + /** + * Dedicated Builder + * + * @since 2.2 + */ + public static class Builder { + + private RetryPolicy retryPolicy; + private Integer maxRetryCount; + private Integer exponentialBackoff; + + protected Builder() {} + + protected Builder(RetryConfiguration configuration) { + if (null == configuration) { + return; + } + this.retryPolicy = configuration.retryPolicy; + this.maxRetryCount = configuration.maxRetryCount; + this.exponentialBackoff = configuration.exponentialBackoff; + } + + /** + * Set the strategy used to compute the delay before a retry + * + * @param retryPolicy Retry policy, or {@code null} to keep the default + * @return Current builder + * @see RetryConfiguration#getRetryPolicy() getter + * @since 2.2 + */ + public Builder setRetryPolicy(RetryPolicy retryPolicy) { + this.retryPolicy = retryPolicy; + return this; + } + + /** + * Set the maximum number of retries performed on top of the initial attempt + * + * @param maxRetryCount Maximum retry count, zero or greater + * @return Current builder + * @see RetryConfiguration#getMaxRetryCount() getter + * @since 2.2 + */ + public Builder setMaxRetryCount(int maxRetryCount) { + this.maxRetryCount = maxRetryCount; + return this; + } + + /** + * Set the growth factor of the exponential backoff + * + * @param exponentialBackoff Growth factor, one or greater + * @return Current builder + * @see RetryConfiguration#getExponentialBackoff() getter + * @since 2.2 + */ + public Builder setExponentialBackoff(int exponentialBackoff) { + this.exponentialBackoff = exponentialBackoff; + return this; + } + + /** + * Build a {@link RetryConfiguration} instance from builder current state + * + * @return RetryConfiguration instance built from current builder state + * @since 2.2 + */ + public RetryConfiguration build() { + if (null != maxRetryCount && maxRetryCount < 0) { + throw new IllegalArgumentException( + "RetryConfiguration: maxRetryCount must be zero or greater, got: " + maxRetryCount); + } + if (null != exponentialBackoff && exponentialBackoff < 1) { + throw new IllegalArgumentException( + "RetryConfiguration: exponentialBackoff must be one or greater, got: " + + exponentialBackoff); + } + return new RetryConfiguration( + null != retryPolicy ? retryPolicy : DEFAULT_RETRY_POLICY, + null != maxRetryCount ? maxRetryCount : DEFAULT_MAX_RETRY_COUNT, + null != exponentialBackoff ? exponentialBackoff : DEFAULT_EXPONENTIAL_BACKOFF); + } + } +} diff --git a/client/src/main/com/sinch/sdk/models/RetryPolicy.java b/client/src/main/com/sinch/sdk/models/RetryPolicy.java new file mode 100644 index 000000000..c8cf9721f --- /dev/null +++ b/client/src/main/com/sinch/sdk/models/RetryPolicy.java @@ -0,0 +1,23 @@ +package com.sinch.sdk.models; + +/** + * Strategy used to compute the delay before retrying a rate-limited request: the {@code + * Retry-After} header the server sent, a client-side exponential backoff, or neither. + * + * @see RetryConfiguration + * @since 2.2 + */ +public enum RetryPolicy { + + /** Honor {@code Retry-After} when the response carries a usable one, otherwise back off. */ + DEFAULT, + + /** Honor {@code Retry-After} only: without a usable one, the response is not retried. */ + RETRY_AFTER, + + /** Exponential backoff only: any {@code Retry-After} header is ignored. */ + BACKOFF, + + /** Disable retries. The rate-limited response is returned to the caller as-is. */ + NONE +} diff --git a/client/src/test/java/com/sinch/sdk/SinchClientRetryTest.java b/client/src/test/java/com/sinch/sdk/SinchClientRetryTest.java new file mode 100644 index 000000000..54149d943 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/SinchClientRetryTest.java @@ -0,0 +1,187 @@ +package com.sinch.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sinch.sdk.core.exceptions.ApiException; +import com.sinch.sdk.domains.numberlookup.models.v2.request.NumberLookupRequest; +import com.sinch.sdk.models.Configuration; +import com.sinch.sdk.models.NumberLookupContext; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SinchClientRetryTest extends BaseTest { + + private static final String AUTH_PATH = "/auth"; + private static final String TOKEN_JSON = + "{\"access_token\":\"test-token\",\"expires_in\":3600,\"token_type\":\"Bearer\"}"; + + private MockWebServer server; + private final AtomicInteger lookupRequests = new AtomicInteger(); + private final List lookupResponses = new ArrayList<>(); + private final AtomicInteger authRequests = new AtomicInteger(); + private final List authResponses = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + lookupRequests.set(0); + authRequests.set(0); + // Routed by path rather than queued, so the token exchange cannot consume a lookup response + server.setDispatcher( + new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String path = null != request.getPath() ? request.getPath() : ""; + if (path.startsWith(AUTH_PATH)) { + int n = authRequests.incrementAndGet(); + if (n <= authResponses.size() && 429 == authResponses.get(n - 1)) { + return new MockResponse().setResponseCode(429).addHeader("Retry-After", "0"); + } + return new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/json") + .setBody(TOKEN_JSON); + } + int n = lookupRequests.incrementAndGet(); + int code = n <= lookupResponses.size() ? lookupResponses.get(n - 1) : 200; + MockResponse response = + new MockResponse() + .setResponseCode(code) + .addHeader("Content-Type", "application/json") + .setBody("{}"); + // Retry-After: 0 keeps the wait to jitter alone, so the test does not sleep + return code == 429 ? response.addHeader("Retry-After", "0") : response; + } + }); + server.start(); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + private SinchClient clientWith(RetryPolicy policy) { + Configuration.Builder builder = + Configuration.builder() + .setKeyId("key") + .setKeySecret("secret") + .setProjectId("project") + .setOAuthUrl(server.url(AUTH_PATH).toString()) + .setNumberLookupContext( + NumberLookupContext.builder() + .setNumberLookupUrl(server.url("/lookup").toString()) + .build()); + if (null != policy) { + builder.setRetryConfiguration(RetryConfiguration.builder().setRetryPolicy(policy).build()); + } + return new SinchClient(builder.build()); + } + + private void lookup(SinchClient client) { + client.lookup().v2().lookup(NumberLookupRequest.builder().setNumber("+46700000000").build()); + } + + @Test + void aDomainEndpointIsRetriedJustLikeTheTokenExchange() { + lookupResponses.add(429); + lookupResponses.add(429); + + try (SinchClientCloser closer = new SinchClientCloser(clientWith(null))) { + lookup(closer.client); + } + + assertEquals( + 3, lookupRequests.get(), "the lookup endpoint itself must be retried, not only /auth"); + } + + @Test + void aDomainEndpointStopsAtTheConfiguredBudget() { + for (int i = 0; i < 10; i++) { + lookupResponses.add(429); + } + + try (SinchClientCloser closer = new SinchClientCloser(clientWith(null))) { + // Budget spent, so the 429 surfaces to the caller through the usual error mapping + ApiException thrown = assertThrows(ApiException.class, () -> lookup(closer.client)); + assertEquals( + 429, thrown.getCode(), "the caller must see the status the server actually sent"); + } + + assertEquals(4, lookupRequests.get(), "default policy allows 3 retries on top of the attempt"); + } + + @Test + void aDomainEndpointHonorsADisabledPolicy() { + lookupResponses.add(429); + + try (SinchClientCloser closer = new SinchClientCloser(clientWith(RetryPolicy.NONE))) { + ApiException thrown = assertThrows(ApiException.class, () -> lookup(closer.client)); + assertEquals(429, thrown.getCode()); + } + + assertEquals(1, lookupRequests.get(), "NONE must reach the domain endpoints too"); + } + + /** + * The token request travels through the same retrying client as the call that triggered it. One + * retry loop must apply to it, not two nested ones — nesting would multiply the budgets and hit + * the auth service {@code (maxRetryCount + 1)^2} times for a single domain call. + */ + @Test + void aRateLimitedTokenRequestIsRetriedOnceOverNotTwice() { + for (int i = 0; i < 10; i++) { + authResponses.add(429); + } + + try (SinchClientCloser closer = new SinchClientCloser(clientWith(null))) { + ApiException thrown = assertThrows(ApiException.class, () -> lookup(closer.client)); + assertEquals( + 429, + thrown.getCode(), + "a rate-limited token exchange must report 429, not an authentication failure"); + } + + assertEquals( + 4, + authRequests.get(), + "one retry budget must apply to the token request, not one budget per attempt"); + assertEquals(0, lookupRequests.get(), "the domain call cannot go out without a token"); + } + + @Test + void aSuccessfulDomainCallIsSentOnce() { + try (SinchClientCloser closer = new SinchClientCloser(clientWith(null))) { + lookup(closer.client); + } + + assertEquals(1, lookupRequests.get()); + } + + /** + * {@link SinchClient#close()} does not throw, so it needs a small shim to be used in try-with. + */ + private static final class SinchClientCloser implements AutoCloseable { + private final SinchClient client; + + private SinchClientCloser(SinchClient client) { + this.client = client; + } + + @Override + public void close() { + client.close(); + } + } +} diff --git a/client/src/test/java/com/sinch/sdk/SinchClientTest.java b/client/src/test/java/com/sinch/sdk/SinchClientTest.java index 8baee2d6a..c06f2ec2b 100644 --- a/client/src/test/java/com/sinch/sdk/SinchClientTest.java +++ b/client/src/test/java/com/sinch/sdk/SinchClientTest.java @@ -7,6 +7,8 @@ import com.sinch.sdk.models.Configuration; import com.sinch.sdk.models.ConversationRegion; import com.sinch.sdk.models.HttpProxyConfiguration; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; import com.sinch.sdk.models.SMSRegion; import com.sinch.sdk.models.VoiceContext; import com.sinch.sdk.models.VoiceRegion; @@ -337,4 +339,43 @@ void proxyConfigurationWiredIntoHttpClient() throws Exception { routePlanner, "HttpClient must use a DefaultProxyRoutePlanner when proxy is configured"); } + + @Test + void retryConfigurationWiredIntoHttpClient() throws Exception { + Configuration configuration = + Configuration.builder() + .setRetryConfiguration( + RetryConfiguration.builder() + .setRetryPolicy(RetryPolicy.RETRY_AFTER) + .setMaxRetryCount(7) + .setExponentialBackoff(2) + .build()) + .build(); + + RetryConfiguration applied = retryConfigurationOf(new SinchClient(configuration)); + + assertEquals(RetryPolicy.RETRY_AFTER, applied.getRetryPolicy()); + assertEquals(7, applied.getMaxRetryCount()); + assertEquals(2, applied.getExponentialBackoff()); + } + + @Test + void defaultRetryConfigurationWiredIntoHttpClient() throws Exception { + RetryConfiguration applied = + retryConfigurationOf(new SinchClient(Configuration.builder().build())); + + assertEquals(RetryPolicy.DEFAULT, applied.getRetryPolicy()); + assertEquals(RetryConfiguration.DEFAULT_MAX_RETRY_COUNT, applied.getMaxRetryCount()); + assertEquals(RetryConfiguration.DEFAULT_EXPONENTIAL_BACKOFF, applied.getExponentialBackoff()); + } + + /** Reaches the policy the client actually handed to its transport, not the one it was given. */ + private static RetryConfiguration retryConfigurationOf(SinchClient client) throws Exception { + Method getHttpClient = SinchClient.class.getDeclaredMethod("getHttpClient"); + getHttpClient.setAccessible(true); + return ((HttpClientApache) getHttpClient.invoke(client)) + .getRetryManager() + .orElseThrow(AssertionError::new) + .getRetryConfiguration(); + } } diff --git a/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java b/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java index 4a8e76763..e925f3f08 100644 --- a/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java +++ b/client/src/test/java/com/sinch/sdk/auth/adapters/OAuthManagerTest.java @@ -7,6 +7,7 @@ import com.adelean.inject.resources.junit.jupiter.TestWithResources; import com.sinch.sdk.BaseTest; import com.sinch.sdk.core.exceptions.ApiAuthException; +import com.sinch.sdk.core.exceptions.ApiException; import com.sinch.sdk.core.http.AuthManager; import com.sinch.sdk.core.http.HttpClient; import com.sinch.sdk.core.http.HttpMapper; @@ -14,21 +15,20 @@ import com.sinch.sdk.core.http.HttpRequest; import com.sinch.sdk.core.http.HttpResponse; import com.sinch.sdk.core.models.ServerConfiguration; -import com.sinch.sdk.core.utils.DateUtil; import com.sinch.sdk.core.utils.Pair; +import com.sinch.sdk.http.DefaultRetryManager; +import com.sinch.sdk.http.RetryCapable; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; import com.sinch.sdk.models.UnifiedCredentials; import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -135,70 +135,73 @@ void noInfiniteLoopAndException() { } @Nested - class RetryOn429WithBackoff { + class RateLimitedTokenRequest { - private OAuthManager spyAuthManager; + private HttpResponse rateLimited() { + Map> headers = new HashMap<>(); + headers.put("Retry-After", Collections.singletonList("0")); + return new HttpResponse(429, "Too Many Requests", headers, null); + } - @BeforeEach - void setup() { - spyAuthManager = - spy( - new OAuthManager( - credentials, - new ServerConfiguration("OAuth url"), - HttpMapper.getInstance(), - () -> httpClient)); + private HttpResponse ok() { + return new HttpResponse( + 200, "foo message", null, jsonResponse.getBytes(StandardCharsets.UTF_8)); } @Test - void retriesOn429ThenSucceeds() { - doReturn(0L).when(spyAuthManager).computeBackoffMillis(any(), anyInt()); - HttpResponse rateLimited = - new HttpResponse(429, "Too Many Requests", Collections.emptyMap(), null); - HttpResponse ok = - new HttpResponse(200, "foo message", null, jsonResponse.getBytes(StandardCharsets.UTF_8)); - - when(httpClient.invokeAPI(any(), any(), any())).thenReturn(rateLimited, rateLimited, ok); + void retriesARateLimitedTokenRequestThenSucceeds() { + when(httpClient.invokeAPI(any(), any(), any())) + .thenReturn(rateLimited(), rateLimited(), ok()); Collection> headers = - spyAuthManager.getAuthorizationHeaders(null, null, null, null); + authManager.getAuthorizationHeaders(null, null, null, null); - assertNotNull(headers); + assertEquals("Bearer token value", headers.iterator().next().getRight()); verify(httpClient, times(3)).invokeAPI(any(), any(), any()); } @Test - void givesUpAfterMaxRetries() { - doReturn(0L).when(spyAuthManager).computeBackoffMillis(any(), anyInt()); - HttpResponse rateLimited = - new HttpResponse(429, "Too Many Requests", Collections.emptyMap(), null); - when(httpClient.invokeAPI(any(), any(), any())).thenReturn(rateLimited); - ApiAuthException exception = + void reportsTheRateLimitOnceTheBudgetIsSpent() { + when(httpClient.invokeAPI(any(), any(), any())).thenReturn(rateLimited()); + + ApiException exception = assertThrows( - ApiAuthException.class, - () -> spyAuthManager.getAuthorizationHeaders(null, null, null, null)); + ApiException.class, + () -> authManager.getAuthorizationHeaders(null, null, null, null)); + + assertEquals( + 429, + exception.getCode(), + "a spent budget on the token exchange must surface the status the server sent," + + " the same one a domain endpoint reports"); assertTrue( exception.getMessage().contains("rate limited by the authentication service (HTTP 429)"), - "expected the give-up message to name the rate limit, got: " + exception.getMessage()); - verify(httpClient, times(OAuthManager.MAX_RATE_LIMIT_RETRIES + 1)) + "expected the failure to name the rate limit, got: " + exception.getMessage()); + verify(httpClient, times(RetryConfiguration.DEFAULT_MAX_RETRY_COUNT + 1)) .invokeAPI(any(), any(), any()); - verify(spyAuthManager, times(OAuthManager.MAX_RATE_LIMIT_RETRIES)) - .computeBackoffMillis(any(), anyInt()); } @Test - void feedsTheRateLimitedResponseToTheBackoff() { - doReturn(0L).when(spyAuthManager).computeBackoffMillis(any(), anyInt()); - Map> headers = new HashMap<>(); - headers.put("Retry-After", Collections.singletonList("5")); - HttpResponse rateLimited = new HttpResponse(429, "Too Many Requests", headers, null); - HttpResponse ok = - new HttpResponse(200, "foo message", null, jsonResponse.getBytes(StandardCharsets.UTF_8)); - when(httpClient.invokeAPI(any(), any(), any())).thenReturn(rateLimited, ok); - spyAuthManager.getAuthorizationHeaders(null, null, null, null); - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpResponse.class); - verify(spyAuthManager).computeBackoffMillis(captor.capture(), eq(0)); - assertSame(rateLimited, captor.getValue()); + void takesThePolicyFromATransportThatCarriesOne() { + HttpClient capable = + mock(HttpClient.class, withSettings().extraInterfaces(RetryCapable.class)); + when(capable.invokeAPI(any(), any(), any())).thenReturn(rateLimited()); + when(((RetryCapable) capable).getRetryManager()) + .thenReturn( + Optional.of( + new DefaultRetryManager( + RetryConfiguration.builder().setRetryPolicy(RetryPolicy.NONE).build()))); + AuthManager manager = + new OAuthManager( + credentials, + new ServerConfiguration("OAuth url"), + HttpMapper.getInstance(), + () -> capable); + + assertThrows( + ApiException.class, () -> manager.getAuthorizationHeaders(null, null, null, null)); + + verify(capable, times(1)).invokeAPI(any(), any(), any()); } } @@ -232,7 +235,6 @@ void networkErrorThrowsWithoutRetryOrBackoff() { "expected a network/client-error cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); - verify(spyAuthManager, never()).computeBackoffMillis(any(), anyInt()); } @Test @@ -250,7 +252,6 @@ void nonSuccessfulStatusThrowsWithoutRetryOrBackoff() { "expected an HTTP-status cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); - verify(spyAuthManager, never()).computeBackoffMillis(any(), anyInt()); } @Test @@ -269,7 +270,6 @@ void successWithoutAccessTokenThrowsWithoutRetryOrBackoff() { "expected the missing-token cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); - verify(spyAuthManager, never()).computeBackoffMillis(any(), anyInt()); } @Test @@ -287,7 +287,6 @@ void deserializationFailureThrowsWithoutRetryOrBackoff() { "expected a deserialization cause, got: " + exception.getMessage()); verify(httpClient, times(1)).invokeAPI(any(), any(), any()); - verify(spyAuthManager, never()).computeBackoffMillis(any(), anyInt()); } } @@ -296,6 +295,8 @@ class ConcurrentRefresh { @Test void concurrentCallersShareASingleRefresh() throws Exception { + Map> retryAfterNow = new HashMap<>(); + retryAfterNow.put("Retry-After", Collections.singletonList("0")); OAuthManager spyAuthManager = spy( new OAuthManager( @@ -303,9 +304,7 @@ void concurrentCallersShareASingleRefresh() throws Exception { new ServerConfiguration("OAuth url"), HttpMapper.getInstance(), () -> httpClient)); - doReturn(0L).when(spyAuthManager).computeBackoffMillis(any(), anyInt()); - HttpResponse rateLimited = - new HttpResponse(429, "Too Many Requests", Collections.emptyMap(), null); + HttpResponse rateLimited = new HttpResponse(429, "Too Many Requests", retryAfterNow, null); HttpResponse ok = new HttpResponse(200, "foo message", null, jsonResponse.getBytes(StandardCharsets.UTF_8)); when(httpClient.invokeAPI(any(), any(), any())).thenReturn(rateLimited, ok); @@ -337,81 +336,4 @@ void concurrentCallersShareASingleRefresh() throws Exception { verify(httpClient, times(2)).invokeAPI(any(), any(), any()); } } - - @Nested - class ComputeBackoff { - - private OAuthManager manager; - - @BeforeEach - void setup() { - manager = - new OAuthManager( - credentials, - new ServerConfiguration("OAuth url"), - HttpMapper.getInstance(), - () -> httpClient); - } - - @Test - void honorsRetryAfter() { - assertBetween(5_000, 5_250, backoff("Retry-After", "5", 0)); - assertBetween( - 4_000, - 5_250, - backoff("Retry-After", DateUtil.instantToRFC822String(Instant.now().plusSeconds(5)), 0)); - - // A zero delay is still a delay, and a date already past means the window has reopened. - assertBetween(0, 250, backoff("Retry-After", "0", 0)); - assertBetween( - 0, - 250, - backoff( - "Retry-After", DateUtil.instantToRFC822String(Instant.now().minusSeconds(3600)), 0)); - - // HTTP/2 lower-cases header names, HTTP/1.1 servers usually do not; both must be honored. - assertBetween(5_000, 5_250, backoff("retry-after", "5", 0)); - - // The two obsolete formats a recipient must still accept (RFC 7231 section 7.1.1.1) - assertBetween(4_000, 5_250, backoff("Retry-After", obsoleteDate("RFC850"), 0)); - assertBetween(4_000, 5_250, backoff("Retry-After", obsoleteDate("asctime"), 0)); - } - - @Test - void fallsBackToExponentialBackoffWhenRetryAfterIsUnusable() { - for (String value : new String[] {"", " ", "abc", "-3", "NaN", "Infinity", "1e30"}) { - assertBetween(0, 1_000, backoff("Retry-After", value, 0)); - } - } - - @Test - void exponentialBackoffGrowsWithEachAttempt() { - assertBetween(0, 1_000, backoff(null, null, 0)); - assertBetween(0, 4_000, backoff(null, null, 1)); - assertBetween(0, 16_000, backoff(null, null, 2)); - } - - private long backoff(String headerName, String headerValue, int attempt) { - Map> headers = new HashMap<>(); - if (null != headerName && null != headerValue) { - headers.put(headerName, Collections.singletonList(headerValue)); - } - return manager.computeBackoffMillis( - new HttpResponse(429, "Too Many Requests", headers, null), attempt); - } - - /** Formats "five seconds from now" in one of the two obsolete HTTP-date forms. */ - private String obsoleteDate(String form) { - ZonedDateTime when = ZonedDateTime.ofInstant(Instant.now().plusSeconds(5), ZoneOffset.UTC); - String pattern = - "RFC850".equals(form) ? "EEEE, dd-MMM-yy HH:mm:ss 'GMT'" : "EEE MMM ppd HH:mm:ss yyyy"; - return DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH).format(when); - } - - private void assertBetween(long lowInclusive, long highInclusive, long actual) { - assertTrue( - actual >= lowInclusive && actual <= highInclusive, - "expected a value in [" + lowInclusive + ", " + highInclusive + "], got: " + actual); - } - } } diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheNoTransportRetryTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheNoTransportRetryTest.java new file mode 100644 index 000000000..f28e7c907 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheNoTransportRetryTest.java @@ -0,0 +1,76 @@ +package com.sinch.sdk.http; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sinch.sdk.core.http.HttpMethod; +import com.sinch.sdk.core.http.HttpRequest; +import com.sinch.sdk.core.http.HttpResponse; +import com.sinch.sdk.core.models.ServerConfiguration; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Apache's own retry layer must stay off, so that the SDK policy is the only thing retrying. */ +class HttpClientApacheNoTransportRetryTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + @Test + void rateLimitedResponseIsReturnedAfterASingleRequest() throws Exception { + // A Retry-After the transport would otherwise honour, uncapped, before returning + server.enqueue(new MockResponse().setResponseCode(429).addHeader("Retry-After", "30")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + HttpResponse response = invoke(); + + assertEquals(429, response.getCode(), "the 429 must reach the caller, not be retried away"); + assertEquals(1, server.getRequestCount(), "the transport must not retry a 429"); + assertNotNull( + response.getHeaders().get("Retry-After"), + "Retry-After must survive to the caller, which owns the backoff"); + } + + @Test + void serviceUnavailableResponseIsReturnedAfterASingleRequest() throws Exception { + server.enqueue(new MockResponse().setResponseCode(503)); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + HttpResponse response = invoke(); + + assertEquals(503, response.getCode(), "the 503 must reach the caller, not be retried away"); + assertEquals(1, server.getRequestCount(), "the transport must not retry a 503"); + } + + private HttpResponse invoke() throws Exception { + RetryConfiguration noSdkRetries = + RetryConfiguration.builder().setRetryPolicy(RetryPolicy.NONE).build(); + try (HttpClientApache client = + new HttpClientApache(null, new DefaultRetryManager(noSdkRetries))) { + HttpResponse response = + client.invokeAPI( + new ServerConfiguration(server.url("/").toString()), + null, + new HttpRequest("", HttpMethod.GET, null, (String) null, null, null, null, null)); + // give a retry, were one to happen, the chance to reach the server before we count + server.takeRequest(1, TimeUnit.SECONDS); + return response; + } + } +} diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheRetryAuthTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheRetryAuthTest.java new file mode 100644 index 000000000..7b8e6e820 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheRetryAuthTest.java @@ -0,0 +1,83 @@ +package com.sinch.sdk.http; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sinch.sdk.auth.adapters.ApplicationAuthManager; +import com.sinch.sdk.core.http.AuthManager; +import com.sinch.sdk.core.http.HttpMethod; +import com.sinch.sdk.core.http.HttpRequest; +import com.sinch.sdk.core.models.ServerConfiguration; +import com.sinch.sdk.models.RetryConfiguration; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * A retry must re-sign the request, not replay the first attempt's headers. ApplicationAuthManager + * (Voice, Verification) signs an x-timestamp, so a retry that waited out a backoff and reused the + * original signature would be rejected by the server rather than succeeding. + */ +class HttpClientApacheRetryAuthTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + @Test + void eachRetryAttemptIsSignedAfresh() throws Exception { + server.enqueue(new MockResponse().setResponseCode(429).addHeader("Retry-After", "1")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + Map auth = + Collections.singletonMap( + "Basic", new ApplicationAuthManager("key", "c2VjcmV0LXZhbHVlLWZvci10ZXN0aW5n")); + + try (HttpClientApache client = + new HttpClientApache( + null, + new DefaultRetryManager(RetryConfiguration.builder().setMaxRetryCount(1).build()))) { + client.invokeAPI( + new ServerConfiguration(server.url("/").toString()), + auth, + new HttpRequest( + "", + HttpMethod.GET, + null, + (String) null, + null, + null, + null, + Collections.singletonList("Basic"))); + } + + assertEquals(2, server.getRequestCount(), "expected the 429 to be retried once"); + RecordedRequest first = server.takeRequest(); + RecordedRequest second = server.takeRequest(); + + assertNotNull(first.getHeader("x-timestamp")); + assertNotNull(second.getHeader("x-timestamp")); + assertNotEquals( + first.getHeader("x-timestamp"), + second.getHeader("x-timestamp"), + "the retry replayed the first attempt's timestamp, so its signature is stale"); + assertNotEquals( + first.getHeader("Authorization"), + second.getHeader("Authorization"), + "a fresh timestamp must produce a fresh signature"); + } +} diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java index 79799e13a..e43a5bd94 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java @@ -9,6 +9,8 @@ import com.sinch.sdk.core.http.HttpRequest; import com.sinch.sdk.core.http.HttpResponse; import com.sinch.sdk.core.models.ServerConfiguration; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; import java.io.ByteArrayInputStream; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -147,4 +149,24 @@ void testInvokeApi407DoesNotTriggerOAuthRefresh() throws Exception { verify(mockAuthManager, never()).resetToken(); verify(client, times(1)).processRequest(any(), any()); } + + @Test + void aClientCarryingNoManagerRetriesNothing() throws Exception { + try (HttpClientApache noRetries = new HttpClientApache(null, null)) { + assertSame(RetryManager.NO_RETRY, noRetries.getRetryManager().get()); + } + } + + @Test + void exposesTheManagerItWasBuiltWith() throws Exception { + RetryConfiguration configured = + RetryConfiguration.builder().setRetryPolicy(RetryPolicy.BACKOFF).build(); + + try (HttpClientApache configuredClient = + new HttpClientApache(null, new DefaultRetryManager(configured))) { + assertEquals( + RetryPolicy.BACKOFF, + configuredClient.getRetryManager().get().getRetryConfiguration().getRetryPolicy()); + } + } } diff --git a/client/src/test/java/com/sinch/sdk/http/RetryManagerTest.java b/client/src/test/java/com/sinch/sdk/http/RetryManagerTest.java new file mode 100644 index 000000000..3f7ea4195 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/http/RetryManagerTest.java @@ -0,0 +1,444 @@ +package com.sinch.sdk.http; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.sinch.sdk.BaseTest; +import com.sinch.sdk.core.exceptions.ApiException; +import com.sinch.sdk.core.http.HttpResponse; +import com.sinch.sdk.core.utils.DateUtil; +import com.sinch.sdk.models.RetryConfiguration; +import com.sinch.sdk.models.RetryPolicy; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.OptionalLong; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class RetryManagerTest extends BaseTest { + + private static HttpResponse rateLimitedNow() { + return rateLimited("Retry-After", "0"); + } + + private static HttpResponse rateLimited(String headerName, String headerValue) { + Map> headers = new HashMap<>(); + if (null != headerName && null != headerValue) { + headers.put(headerName, Collections.singletonList(headerValue)); + } + return new HttpResponse(429, "Too Many Requests", headers, null); + } + + private static HttpResponse rateLimited() { + return rateLimited(null, null); + } + + private static HttpResponse ok() { + return new HttpResponse(200, "OK", Collections.emptyMap(), null); + } + + private static DefaultRetryManager manager(RetryPolicy policy) { + return new DefaultRetryManager(RetryConfiguration.builder().setRetryPolicy(policy).build()); + } + + private static final class ScriptedCall implements Callable { + + private final List script; + private final AtomicInteger callsCount = new AtomicInteger(); + + private ScriptedCall(HttpResponse... responses) { + this.script = new ArrayList<>(Arrays.asList(responses)); + } + + @Override + public HttpResponse call() { + int n = callsCount.incrementAndGet(); + return script.get(Math.min(n, script.size()) - 1); + } + + private int callsCount() { + return callsCount.get(); + } + } + + private static boolean isLoopActive() { + return Boolean.TRUE.equals(DefaultRetryManager.LOOP_ACTIVE.get()); + } + + @AfterEach + void loopFlagMustNotLeakBetweenTests() { + assertFalse(isLoopActive(), "a retry loop must never outlive the call that opened it"); + } + + @Nested + class RetryLoop { + + @Test + void retriesUntilTheRequestSucceeds() throws Exception { + ScriptedCall call = new ScriptedCall(rateLimitedNow(), rateLimitedNow(), ok()); + + HttpResponse response = new DefaultRetryManager(RetryConfiguration.DEFAULTS).execute(call); + + assertEquals(200, response.getCode()); + assertEquals(3, call.callsCount()); + } + + @Test + void returnsTheRateLimitedResponseOnceTheBudgetIsSpent() throws Exception { + ScriptedCall call = new ScriptedCall(rateLimitedNow()); + + HttpResponse response = new DefaultRetryManager(RetryConfiguration.DEFAULTS).execute(call); + + assertEquals( + 429, response.getCode(), "the caller must see the response the server actually sent"); + assertEquals(RetryConfiguration.DEFAULT_MAX_RETRY_COUNT + 1, call.callsCount()); + } + + @Test + void honorsAConfiguredRetryCount() throws Exception { + ScriptedCall call = new ScriptedCall(rateLimitedNow()); + + HttpResponse response = + new DefaultRetryManager(RetryConfiguration.builder().setMaxRetryCount(5).build()) + .execute(call); + + assertEquals(429, response.getCode()); + assertEquals(6, call.callsCount()); + } + + @Test + void aRetryCountOfZeroDisablesRetrying() throws Exception { + ScriptedCall call = new ScriptedCall(rateLimitedNow()); + + HttpResponse response = + new DefaultRetryManager(RetryConfiguration.builder().setMaxRetryCount(0).build()) + .execute(call); + + assertEquals(429, response.getCode()); + assertEquals(1, call.callsCount()); + } + + @Test + void doesNotRetryASuccessfulResponse() throws Exception { + ScriptedCall call = new ScriptedCall(ok()); + + assertEquals( + 200, new DefaultRetryManager(RetryConfiguration.DEFAULTS).execute(call).getCode()); + assertEquals(1, call.callsCount()); + } + + @Test + void doesNotRetryOtherErrorStatuses() throws Exception { + // 429 is the only status this ticket puts under retry: a 5xx must reach the caller untouched + for (int status : new int[] {400, 401, 403, 404, 500, 502, 503}) { + ScriptedCall call = + new ScriptedCall(new HttpResponse(status, "error", Collections.emptyMap(), null)); + + HttpResponse response = new DefaultRetryManager(RetryConfiguration.DEFAULTS).execute(call); + + assertEquals(status, response.getCode()); + assertEquals(1, call.callsCount()); + } + } + + @Test + void aNullResponseIsHandedBackUntouched() throws Exception { + AtomicInteger callsCount = new AtomicInteger(); + + HttpResponse response = + new DefaultRetryManager(RetryConfiguration.DEFAULTS) + .execute( + () -> { + callsCount.incrementAndGet(); + return null; + }); + + assertNull(response); + assertEquals(1, callsCount.get()); + } + + @Test + void propagatesAFailureWithoutRetrying() { + AtomicInteger callsCount = new AtomicInteger(); + + assertThrows( + ApiException.class, + () -> + new DefaultRetryManager(RetryConfiguration.DEFAULTS) + .execute( + () -> { + callsCount.incrementAndGet(); + throw new ApiException("connection reset"); + })); + + assertEquals( + 1, callsCount.get(), "a transport failure is final, only a 429 response is retried"); + } + + @Test + void stopsWhenThePolicyDeclinesToProvideADelay() throws Exception { + // RETRY_AFTER with no header: nothing to wait on, so the response goes straight back + ScriptedCall call = new ScriptedCall(rateLimited()); + + HttpResponse response = manager(RetryPolicy.RETRY_AFTER).execute(call); + + assertEquals(429, response.getCode()); + assertEquals(1, call.callsCount()); + } + } + + /** + * One call can reach the manager twice — OAuthManager wraps the token exchange, and the transport + * performing it wraps the exchange in turn. The budget belongs to the logical request, so it must + * not multiply across those levels. + */ + @Nested + class NestedCalls { + + @Test + void anInnerCallDefersToTheLoopThatOwnsTheBudget() throws Exception { + DefaultRetryManager manager = new DefaultRetryManager(RetryConfiguration.DEFAULTS); + AtomicInteger requests = new AtomicInteger(); + + HttpResponse response = + manager.execute( + () -> + manager.execute( + () -> { + requests.incrementAndGet(); + return rateLimitedNow(); + })); + + assertEquals(429, response.getCode()); + assertEquals( + RetryConfiguration.DEFAULT_MAX_RETRY_COUNT + 1, + requests.get(), + "nesting must not square the budget: 4 attempts, not 16"); + } + + @Test + void anInnerCallDefersEvenToADifferentManager() throws Exception { + DefaultRetryManager outer = new DefaultRetryManager(RetryConfiguration.DEFAULTS); + RetryManager inner = + new DefaultRetryManager(RetryConfiguration.builder().setMaxRetryCount(9).build()); + AtomicInteger requests = new AtomicInteger(); + + outer.execute( + () -> + inner.execute( + () -> { + requests.incrementAndGet(); + return rateLimitedNow(); + })); + + assertEquals( + RetryConfiguration.DEFAULT_MAX_RETRY_COUNT + 1, + requests.get(), + "the outermost loop owns the policy; the inner one performs the request once"); + } + + @Test + void aRequestSeesTheLoopAsActive() throws Exception { + new DefaultRetryManager(RetryConfiguration.DEFAULTS) + .execute( + () -> { + assertTrue(isLoopActive()); + return ok(); + }); + + assertFalse(isLoopActive()); + } + + @Test + void theLoopIsClosedEvenWhenTheRequestFails() { + assertThrows( + ApiException.class, + () -> + new DefaultRetryManager(RetryConfiguration.DEFAULTS) + .execute( + () -> { + throw new ApiException("boom"); + })); + + assertFalse(isLoopActive(), "a failure must not leave the loop flag set"); + } + + @Test + void aSeparateThreadGetsItsOwnBudget() throws Exception { + DefaultRetryManager manager = new DefaultRetryManager(RetryConfiguration.DEFAULTS); + AtomicInteger requests = new AtomicInteger(); + + manager.execute( + () -> { + // A concurrent caller must not be starved of retries by this thread's loop + Thread other = + new Thread( + () -> { + try { + manager.execute( + () -> { + requests.incrementAndGet(); + return rateLimitedNow(); + }); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + other.start(); + other.join(); + return ok(); + }); + + assertEquals(RetryConfiguration.DEFAULT_MAX_RETRY_COUNT + 1, requests.get()); + } + } + + @Nested + class PolicyResolution { + + @Test + void aNullConfigurationMeansTheDefaults() { + assertEquals( + RetryConfiguration.DEFAULTS.getRetryPolicy(), + new DefaultRetryManager(null).getRetryConfiguration().getRetryPolicy()); + } + } + + @Nested + class Policies { + + @Test + void defaultHonorsTheHeaderAndFallsBackToBackoff() { + DefaultRetryManager manager = new DefaultRetryManager(RetryConfiguration.DEFAULTS); + + assertBetween(5_000, 5_250, manager.computeBackoffMillis(rateLimited("Retry-After", "5"), 0)); + assertBetween(0, 1_000, manager.computeBackoffMillis(rateLimited(), 0)); + } + + @Test + void retryAfterHonorsTheHeaderAndOtherwiseGivesUp() { + DefaultRetryManager manager = manager(RetryPolicy.RETRY_AFTER); + + assertBetween(5_000, 5_250, manager.computeBackoffMillis(rateLimited("Retry-After", "5"), 0)); + assertFalse( + manager.computeBackoffMillis(rateLimited(), 0).isPresent(), + "RETRY_AFTER has no fallback: without a usable header there is nothing to wait on"); + } + + @Test + void backoffIgnoresTheHeaderEntirely() { + // A header asking for an hour must not stretch the first backoff beyond its one-second + // ceiling + assertBetween( + 0, + 1_000, + manager(RetryPolicy.BACKOFF).computeBackoffMillis(rateLimited("Retry-After", "3600"), 0)); + } + + @Test + void noneDeclinesEveryDelay() throws Exception { + DefaultRetryManager manager = manager(RetryPolicy.NONE); + ScriptedCall call = new ScriptedCall(rateLimited("Retry-After", "5"), ok()); + + assertEquals(429, manager.execute(call).getCode()); + assertEquals(1, call.callsCount()); + assertFalse(manager.computeBackoffMillis(rateLimited("Retry-After", "5"), 0).isPresent()); + } + } + + @Nested + class RetryAfterParsing { + + private final DefaultRetryManager manager = + new DefaultRetryManager(RetryConfiguration.DEFAULTS); + + @Test + void honorsBothFormsAllowedByTheSpec() { + assertBetween(5_000, 5_250, backoff("Retry-After", "5", 0)); + assertBetween( + 4_000, + 5_250, + backoff("Retry-After", DateUtil.instantToRFC822String(Instant.now().plusSeconds(5)), 0)); + + // A zero delay is still a delay, and a date already past means the window has reopened. + assertBetween(0, 250, backoff("Retry-After", "0", 0)); + assertBetween( + 0, + 250, + backoff( + "Retry-After", DateUtil.instantToRFC822String(Instant.now().minusSeconds(3600)), 0)); + + // HTTP/2 lower-cases header names, HTTP/1.1 servers usually do not; both must be honored. + assertBetween(5_000, 5_250, backoff("retry-after", "5", 0)); + + // The two obsolete formats a recipient must still accept (RFC 7231 section 7.1.1.1) + assertBetween(4_000, 5_250, backoff("Retry-After", obsoleteDate("RFC850"), 0)); + assertBetween(4_000, 5_250, backoff("Retry-After", obsoleteDate("asctime"), 0)); + } + + @Test + void fallsBackToExponentialBackoffWhenTheHeaderIsUnusable() { + for (String value : new String[] {"", " ", "abc", "-3", "NaN", "Infinity", "1e30"}) { + assertBetween(0, 1_000, backoff("Retry-After", value, 0)); + } + } + + @Test + void exponentialBackoffGrowsWithEachAttempt() { + assertBetween(0, 1_000, backoff(null, null, 0)); + assertBetween(0, 4_000, backoff(null, null, 1)); + assertBetween(0, 16_000, backoff(null, null, 2)); + } + + @Test + void exponentialBackoffFollowsTheConfiguredGrowthFactor() { + DefaultRetryManager doubling = + new DefaultRetryManager(RetryConfiguration.builder().setExponentialBackoff(2).build()); + + assertBetween(0, 1_000, doubling.computeBackoffMillis(rateLimited(), 0)); + assertBetween(0, 2_000, doubling.computeBackoffMillis(rateLimited(), 1)); + assertBetween(0, 4_000, doubling.computeBackoffMillis(rateLimited(), 2)); + } + + @Test + void anExtremeRetryBudgetStillYieldsAFiniteDelay() { + // growth^attempt overflows to infinity long before this; the delay must stay a real number + assertBetween(0, 3_600_000, manager.computeBackoffMillis(rateLimited(), 5_000)); + } + + private OptionalLong backoff(String headerName, String headerValue, int attempt) { + return manager.computeBackoffMillis(rateLimited(headerName, headerValue), attempt); + } + + /** Formats "five seconds from now" in one of the two obsolete HTTP-date forms. */ + private String obsoleteDate(String form) { + ZonedDateTime when = ZonedDateTime.ofInstant(Instant.now().plusSeconds(5), ZoneOffset.UTC); + String pattern = + "RFC850".equals(form) ? "EEEE, dd-MMM-yy HH:mm:ss 'GMT'" : "EEE MMM ppd HH:mm:ss yyyy"; + return DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH).format(when); + } + } + + private static void assertBetween(long lowInclusive, long highInclusive, OptionalLong actual) { + assertTrue(actual.isPresent(), "expected a delay, got none"); + assertBetween(lowInclusive, highInclusive, actual.getAsLong()); + } + + private static void assertBetween(long lowInclusive, long highInclusive, long actual) { + assertTrue( + actual >= lowInclusive && actual <= highInclusive, + "expected a value in [" + lowInclusive + ", " + highInclusive + "], got: " + actual); + } +} diff --git a/client/src/test/java/com/sinch/sdk/models/RetryConfigurationTest.java b/client/src/test/java/com/sinch/sdk/models/RetryConfigurationTest.java new file mode 100644 index 000000000..6de879521 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/models/RetryConfigurationTest.java @@ -0,0 +1,173 @@ +package com.sinch.sdk.models; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sinch.sdk.BaseTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class RetryConfigurationTest extends BaseTest { + + @Nested + class Defaults { + + @Test + void anUnconfiguredInstanceCarriesTheDocumentedDefaults() { + RetryConfiguration configuration = RetryConfiguration.builder().build(); + + assertEquals(RetryPolicy.DEFAULT, configuration.getRetryPolicy()); + assertEquals(3, configuration.getMaxRetryCount()); + assertEquals(4, configuration.getExponentialBackoff()); + } + + @Test + void theDefaultConstantsAreThemselvesValid() { + assertEquals( + RetryConfiguration.DEFAULT_RETRY_POLICY, RetryConfiguration.DEFAULTS.getRetryPolicy()); + assertEquals( + RetryConfiguration.DEFAULT_MAX_RETRY_COUNT, + RetryConfiguration.DEFAULTS.getMaxRetryCount()); + assertEquals( + RetryConfiguration.DEFAULT_EXPONENTIAL_BACKOFF, + RetryConfiguration.DEFAULTS.getExponentialBackoff()); + } + + @Test + void theDefaultsInstanceMatchesAnEmptyBuilder() { + RetryConfiguration defaults = RetryConfiguration.DEFAULTS; + + assertEquals(RetryConfiguration.DEFAULT_RETRY_POLICY, defaults.getRetryPolicy()); + assertEquals(RetryConfiguration.DEFAULT_MAX_RETRY_COUNT, defaults.getMaxRetryCount()); + assertEquals( + RetryConfiguration.DEFAULT_EXPONENTIAL_BACKOFF, defaults.getExponentialBackoff()); + } + + @Test + void eachFieldFallsBackIndependently() { + RetryConfiguration configuration = + RetryConfiguration.builder().setRetryPolicy(RetryPolicy.BACKOFF).build(); + + assertEquals(RetryPolicy.BACKOFF, configuration.getRetryPolicy()); + assertEquals( + RetryConfiguration.DEFAULT_MAX_RETRY_COUNT, + configuration.getMaxRetryCount(), + "setting one field must not disturb the others"); + } + } + + @Nested + class Validation { + + @Test + void rejectsANegativeRetryCount() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> RetryConfiguration.builder().setMaxRetryCount(-1).build()); + assertTrue(exception.getMessage().contains("maxRetryCount")); + } + + @Test + void acceptsAZeroRetryCountAsAWayToDisableRetries() { + assertEquals(0, RetryConfiguration.builder().setMaxRetryCount(0).build().getMaxRetryCount()); + } + + @Test + void rejectsABackoffFactorBelowOne() { + // A factor under one would shrink the delay on every attempt instead of growing it + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> RetryConfiguration.builder().setExponentialBackoff(0).build()); + assertTrue(exception.getMessage().contains("exponentialBackoff")); + } + } + + @Nested + class BuilderRoundTrip { + + @Test + void anExistingConfigurationRebuildsIdentically() { + RetryConfiguration source = + RetryConfiguration.builder() + .setRetryPolicy(RetryPolicy.RETRY_AFTER) + .setMaxRetryCount(7) + .setExponentialBackoff(2) + .build(); + + RetryConfiguration copy = RetryConfiguration.builder(source).build(); + + assertEquals(source.getRetryPolicy(), copy.getRetryPolicy()); + assertEquals(source.getMaxRetryCount(), copy.getMaxRetryCount()); + assertEquals(source.getExponentialBackoff(), copy.getExponentialBackoff()); + } + + @Test + void aNullSourceLeavesTheBuilderAtItsDefaults() { + RetryConfiguration configuration = RetryConfiguration.builder(null).build(); + + assertEquals(RetryPolicy.DEFAULT, configuration.getRetryPolicy()); + } + } + + @Nested + class OnSinchConfiguration { + + @Test + void anUnconfiguredClientReportsNoPolicyAndFallsBackToTheDefaults() { + Configuration configuration = Configuration.builder().build(); + + assertFalse( + configuration.getRetryConfiguration().isPresent(), + "nothing was configured, so the getter must be empty like the other optional ones"); + assertEquals(RetryPolicy.DEFAULT, RetryConfiguration.DEFAULTS.getRetryPolicy()); + } + + @Test + void acceptsAWholeRetryConfiguration() { + Configuration configuration = + Configuration.builder() + .setRetryConfiguration( + RetryConfiguration.builder().setRetryPolicy(RetryPolicy.NONE).build()) + .build(); + + assertEquals(RetryPolicy.NONE, configuration.getRetryConfiguration().get().getRetryPolicy()); + } + + @Test + void acceptsTheIndividualSettingsAndCombinesThem() { + Configuration configuration = + Configuration.builder() + .setRetryConfiguration( + RetryConfiguration.builder() + .setRetryPolicy(RetryPolicy.BACKOFF) + .setMaxRetryCount(5) + .setExponentialBackoff(2) + .build()) + .build(); + + RetryConfiguration retry = configuration.getRetryConfiguration().get(); + assertEquals(RetryPolicy.BACKOFF, retry.getRetryPolicy()); + assertEquals(5, retry.getMaxRetryCount()); + assertEquals(2, retry.getExponentialBackoff()); + } + + @Test + void survivesRebuildingTheConfiguration() { + // SinchClient rebuilds the configuration it is given, so the policy must make the round trip + Configuration source = + Configuration.builder() + .setRetryConfiguration(RetryConfiguration.builder().setMaxRetryCount(9).build()) + .build(); + + Configuration rebuilt = Configuration.builder(source).build(); + + assertEquals(9, rebuilt.getRetryConfiguration().get().getMaxRetryCount()); + } + + @Test + void isReportedInToString() { + assertTrue(Configuration.builder().build().toString().contains("retryConfiguration=")); + } + } +} From 67d6e779b9bb0d2879ce3c75b74641a5a7f31d29 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 17 Sep 2026 09:22:29 +0200 Subject: [PATCH 13/21] DEVEXP-1652: Support DICE in customCallout (Voice V1), sources generated --- .../request/CalloutRequestConference.java | 1 + .../request/CalloutRequestCustom.java | 20 +++++ .../request/CalloutRequestCustomImpl.java | 24 ++++++ .../callouts/request/CalloutRequestTTS.java | 1 + .../internal/CustomCalloutInternal.java | 19 +++++ .../internal/CustomCalloutInternalImpl.java | 37 +++++++-- .../v1/calls/response/CallInformation.java | 23 ++++++ .../v1/conferences/ConferenceDtmfOptions.java | 14 ++++ .../ManageConferenceParticipantRequest.java | 7 ++ .../v1/destination/DestinationWebSocket.java | 1 + .../v1/sinchevents/AnsweredCallEvent.java | 1 + .../AnsweringMachineDetection.java | 17 ++++ .../v1/sinchevents/DisconnectedCallEvent.java | 30 +++++++ .../models/v1/sinchevents/MenuResult.java | 14 ++++ .../v1/sinchevents/NotificationEvent.java | 1 + .../v1/sinchevents/PromptInputEvent.java | 1 + .../action/SvamlActionConnectConference.java | 1 + .../svaml/action/SvamlActionConnectMxp.java | 1 + .../svaml/action/SvamlActionConnectPstn.java | 78 +++++++++++++++++++ .../svaml/action/SvamlActionConnectSip.java | 6 ++ .../action/SvamlActionConnectStream.java | 1 + .../v1/svaml/action/SvamlActionContinue.java | 1 + .../v1/svaml/action/SvamlActionHangup.java | 1 + .../v1/svaml/action/SvamlActionPark.java | 1 + .../v1/svaml/action/SvamlActionRunMenu.java | 1 + .../instruction/SvamlInstructionAnswer.java | 1 + .../SvamlInstructionPlayFiles.java | 1 + .../instruction/SvamlInstructionSay.java | 1 + .../instruction/SvamlInstructionSendDtmf.java | 1 + .../SvamlInstructionSetCookie.java | 1 + .../SvamlInstructionStartRecording.java | 1 + .../SvamlInstructionStopRecording.java | 1 + 32 files changed, 304 insertions(+), 5 deletions(-) diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestConference.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestConference.java index 27d254679..efb499b72 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestConference.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestConference.java @@ -28,6 +28,7 @@ public interface CalloutRequestConference /** Gets or Sets method */ public class MethodEnum extends EnumDynamic { + /** Define conferenceCallout request */ public static final MethodEnum CONFERENCE_CALLOUT = new MethodEnum("conferenceCallout"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestCustom.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestCustom.java index 4e6112d2c..f7fa3347b 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestCustom.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestCustom.java @@ -26,6 +26,7 @@ public interface CalloutRequestCustom /** Gets or Sets method */ public class MethodEnum extends EnumDynamic { + /** Define customCallout request */ public static final MethodEnum CUSTOM_CALLOUT = new MethodEnum("customCallout"); private static final EnumSupportDynamic ENUM_SUPPORT = @@ -129,6 +130,16 @@ public static String valueOf(MethodEnum e) { */ Control getPie(); + /** + * URL of the callback server that will receive the DiCE event when the call is disconnected. The + * DiCE event will contain information about the call, such as the call duration, the reason for + * disconnection, and any custom data that was included in the callout request. Example: + * \"https://your-application-server-host/application\" + * + * @return dice + */ + String getDice(); + /** * Getting builder * @@ -213,6 +224,15 @@ interface Builder { */ Builder setPie(Control pie); + /** + * see getter + * + * @param dice see getter + * @return Current builder + * @see #getDice + */ + Builder setDice(String dice); + /** * Create instance * diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestCustomImpl.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestCustomImpl.java index be75f250b..fd7a8e508 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestCustomImpl.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestCustomImpl.java @@ -215,6 +215,24 @@ public OptionalValue pie() { : OptionalValue.empty(); } + @JsonIgnore + public String getDice() { + if (null == customCallout + || !customCallout.isPresent() + || null == customCallout.get().getDice()) { + return null; + } + return customCallout.get().getDice(); + } + + public OptionalValue dice() { + return null != customCallout && customCallout.isPresent() + ? customCallout + .map(f -> ((CustomCalloutInternalImpl) f).dice()) + .orElse(OptionalValue.empty()) + : OptionalValue.empty(); + } + /** Return true if this customCalloutRequest object is equal to o. */ @Override public boolean equals(Object o) { @@ -324,6 +342,12 @@ public Builder setPie(Control pie) { return this; } + @JsonIgnore + public Builder setDice(String dice) { + getDelegatedBuilder().setDice(dice); + return this; + } + private CustomCalloutInternal.Builder getDelegatedBuilder() { if (null == _delegatedBuilder) { this._delegatedBuilder = CustomCalloutInternal.builder(); diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestTTS.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestTTS.java index 6286383ee..b3cdce44b 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestTTS.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/CalloutRequestTTS.java @@ -26,6 +26,7 @@ public interface CalloutRequestTTS /** Gets or Sets method */ public class MethodEnum extends EnumDynamic { + /** Define ttsC allout request */ public static final MethodEnum TTS_CALLOUT = new MethodEnum("ttsCallout"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/internal/CustomCalloutInternal.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/internal/CustomCalloutInternal.java index 9cdbd0c76..3fc0c4986 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/internal/CustomCalloutInternal.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/internal/CustomCalloutInternal.java @@ -103,6 +103,16 @@ public interface CustomCalloutInternal { */ Control getPie(); + /** + * URL of the callback server that will receive the DiCE event when the call is disconnected. The + * DiCE event will contain information about the call, such as the call duration, the reason for + * disconnection, and any custom data that was included in the callout request. Example: + * \"https://your-application-server-host/application\" + * + * @return dice + */ + String getDice(); + /** * Getting builder * @@ -187,6 +197,15 @@ interface Builder { */ Builder setPie(Control pie); + /** + * see getter + * + * @param dice see getter + * @return Current builder + * @see #getDice + */ + Builder setDice(String dice); + /** * Create instance * diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/internal/CustomCalloutInternalImpl.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/internal/CustomCalloutInternalImpl.java index f7a71af9c..bc900e482 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/internal/CustomCalloutInternalImpl.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/callouts/request/internal/CustomCalloutInternalImpl.java @@ -20,7 +20,8 @@ CustomCalloutInternalImpl.JSON_PROPERTY_MAX_DURATION, CustomCalloutInternalImpl.JSON_PROPERTY_ICE, CustomCalloutInternalImpl.JSON_PROPERTY_ACE, - CustomCalloutInternalImpl.JSON_PROPERTY_PIE + CustomCalloutInternalImpl.JSON_PROPERTY_PIE, + CustomCalloutInternalImpl.JSON_PROPERTY_DICE }) @JsonFilter("uninitializedFilter") @JsonInclude(value = JsonInclude.Include.CUSTOM) @@ -59,6 +60,10 @@ public class CustomCalloutInternalImpl implements CustomCalloutInternal { private OptionalValue pie; + public static final String JSON_PROPERTY_DICE = "dice"; + + private OptionalValue dice; + public CustomCalloutInternalImpl() {} protected CustomCalloutInternalImpl( @@ -69,7 +74,8 @@ protected CustomCalloutInternalImpl( OptionalValue maxDuration, OptionalValue ice, OptionalValue ace, - OptionalValue pie) { + OptionalValue pie, + OptionalValue dice) { this.cli = cli; this.destination = destination; this.dtmf = dtmf; @@ -78,6 +84,7 @@ protected CustomCalloutInternalImpl( this.ice = ice; this.ace = ace; this.pie = pie; + this.dice = dice; } @JsonIgnore @@ -168,6 +175,17 @@ public OptionalValue pie() { return pie; } + @JsonIgnore + public String getDice() { + return dice.orElse(null); + } + + @JsonProperty(JSON_PROPERTY_DICE) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OptionalValue dice() { + return dice; + } + /** Return true if this customCallout object is equal to o. */ @Override public boolean equals(Object o) { @@ -185,12 +203,13 @@ public boolean equals(Object o) { && Objects.equals(this.maxDuration, customCallout.maxDuration) && Objects.equals(this.ice, customCallout.ice) && Objects.equals(this.ace, customCallout.ace) - && Objects.equals(this.pie, customCallout.pie); + && Objects.equals(this.pie, customCallout.pie) + && Objects.equals(this.dice, customCallout.dice); } @Override public int hashCode() { - return Objects.hash(cli, destination, dtmf, custom, maxDuration, ice, ace, pie); + return Objects.hash(cli, destination, dtmf, custom, maxDuration, ice, ace, pie, dice); } @Override @@ -205,6 +224,7 @@ public String toString() { sb.append(" ice: ").append(toIndentedString(ice)).append("\n"); sb.append(" ace: ").append(toIndentedString(ace)).append("\n"); sb.append(" pie: ").append(toIndentedString(pie)).append("\n"); + sb.append(" dice: ").append(toIndentedString(dice)).append("\n"); sb.append("}"); return sb.toString(); } @@ -229,6 +249,7 @@ static class Builder implements CustomCalloutInternal.Builder { OptionalValue ice = OptionalValue.empty(); OptionalValue ace = OptionalValue.empty(); OptionalValue pie = OptionalValue.empty(); + OptionalValue dice = OptionalValue.empty(); @JsonProperty(JSON_PROPERTY_CLI) public Builder setCli(String cli) { @@ -278,9 +299,15 @@ public Builder setPie(Control pie) { return this; } + @JsonProperty(JSON_PROPERTY_DICE) + public Builder setDice(String dice) { + this.dice = OptionalValue.of(dice); + return this; + } + public CustomCalloutInternal build() { return new CustomCalloutInternalImpl( - cli, destination, dtmf, custom, maxDuration, ice, ace, pie); + cli, destination, dtmf, custom, maxDuration, ice, ace, pie, dice); } } } diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/calls/response/CallInformation.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/calls/response/CallInformation.java index 478aa77ef..74d240855 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/calls/response/CallInformation.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/calls/response/CallInformation.java @@ -38,6 +38,7 @@ public interface CallInformation { /** Must be pstn for PSTN. */ public class DomainEnum extends EnumDynamic { + /** The Public Switched Telephone Network, or a normal phone call. */ public static final DomainEnum PSTN = new DomainEnum("pstn"); private static final EnumSupportDynamic ENUM_SUPPORT = @@ -83,7 +84,10 @@ public static String valueOf(DomainEnum e) { /** The status of the call. Either ONGOING or FINAL */ public class StatusEnum extends EnumDynamic { + /** Call status is ONGOING */ public static final StatusEnum ONGOING = new StatusEnum("ONGOING"); + + /** Call status is FINAL */ public static final StatusEnum FINAL = new StatusEnum("FINAL"); private static final EnumSupportDynamic ENUM_SUPPORT = @@ -122,15 +126,34 @@ public static String valueOf(StatusEnum e) { /** Contains the reason why a call ended. */ public class ReasonEnum extends EnumDynamic { + /** Not available. */ public static final ReasonEnum N_A = new ReasonEnum("N/A"); + + /** Timed out. */ public static final ReasonEnum TIMEOUT = new ReasonEnum("TIMEOUT"); + + /** Caller hung up. */ public static final ReasonEnum CALLERHANGUP = new ReasonEnum("CALLERHANGUP"); + + /** Callee hung up. */ public static final ReasonEnum CALLEEHANGUP = new ReasonEnum("CALLEEHANGUP"); + + /** The call was blocked. */ public static final ReasonEnum BLOCKED = new ReasonEnum("BLOCKED"); + + /** No credit available. */ public static final ReasonEnum NOCREDITPARTNER = new ReasonEnum("NOCREDITPARTNER"); + + /** The Sinch server ended the call. */ public static final ReasonEnum MANAGERHANGUP = new ReasonEnum("MANAGERHANGUP"); + + /** Call was canceled. */ public static final ReasonEnum CANCEL = new ReasonEnum("CANCEL"); + + /** A general error. */ public static final ReasonEnum GENERALERROR = new ReasonEnum("GENERALERROR"); + + /** Call could not be completed due to invalid SVAML response. */ public static final ReasonEnum INVALIDSVAMLACTION = new ReasonEnum("INVALIDSVAMLACTION"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/conferences/ConferenceDtmfOptions.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/conferences/ConferenceDtmfOptions.java index ccc04b689..ae15b160b 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/conferences/ConferenceDtmfOptions.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/conferences/ConferenceDtmfOptions.java @@ -26,8 +26,22 @@ public interface ConferenceDtmfOptions { /** Determines what DTMF mode the participant will use in the call. */ public class ModeEnum extends EnumDynamic { + /** + * Nothing is done with the participant's DTMF signals. This is the default mode. Any DTMF + * signals that the participant sends can still be heard by all participants, but no action will + * be performed. + */ public static final ModeEnum IGNORE = new ModeEnum("ignore"); + + /** The participant's DTMF signals are forwarded to all other participants in the conference. */ public static final ModeEnum FORWARD = new ModeEnum("forward"); + + /** + * The participant's DTMF signals are detected by the conference and sent to your backend server + * using a Prompt Input + * Event (PIE) callback. + */ public static final ModeEnum DETECT = new ModeEnum("detect"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/conferences/request/ManageConferenceParticipantRequest.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/conferences/request/ManageConferenceParticipantRequest.java index e7974f872..aeb0c0792 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/conferences/request/ManageConferenceParticipantRequest.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/conferences/request/ManageConferenceParticipantRequest.java @@ -23,9 +23,16 @@ public interface ManageConferenceParticipantRequest { /** Action to apply on conference participant. */ public class CommandEnum extends EnumDynamic { + /** Mutes participant. */ public static final CommandEnum MUTE = new CommandEnum("mute"); + + /** Unmutes participant. */ public static final CommandEnum UNMUTE = new CommandEnum("unmute"); + + /** Puts participant on hold. */ public static final CommandEnum ONHOLD = new CommandEnum("onhold"); + + /** Returns participant to conference. */ public static final CommandEnum RESUME = new CommandEnum("resume"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/destination/DestinationWebSocket.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/destination/DestinationWebSocket.java index 52a0b692e..ee7c0d940 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/destination/DestinationWebSocket.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/destination/DestinationWebSocket.java @@ -30,6 +30,7 @@ static DestinationWebSocket from(String endPoint) { * supported. */ public class TypeEnum extends EnumDynamic { + /** Use websocket stream protocol */ public static final TypeEnum WEBSOCKET = new TypeEnum("Websocket"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/AnsweredCallEvent.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/AnsweredCallEvent.java index 4dc77638b..bd9d4d927 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/AnsweredCallEvent.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/AnsweredCallEvent.java @@ -45,6 +45,7 @@ public interface AnsweredCallEvent extends VoiceSinchEvent, VoiceCallSinchEvent /** Must have the value ace. */ public class SinchEventType extends EnumDynamic { + /** An Answered Call Event. */ public static final SinchEventType ACE = new SinchEventType("ace"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/AnsweringMachineDetection.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/AnsweringMachineDetection.java index b15b26987..88f12dfbb 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/AnsweringMachineDetection.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/AnsweringMachineDetection.java @@ -26,9 +26,16 @@ public interface AnsweringMachineDetection { /** The determination by the system of who answered the call. */ public class StatusEnum extends EnumDynamic { + /** An answering machine was detected as answering the call. */ public static final StatusEnum MACHINE = new StatusEnum("machine"); + + /** A human was detected as answering the call. */ public static final StatusEnum HUMAN = new StatusEnum("human"); + + /** The system was unable to determine who answered the call. */ public static final StatusEnum NOTSURE = new StatusEnum("notsure"); + + /** The call was hung up. */ public static final StatusEnum HANGUP = new StatusEnum("hangup"); private static final EnumSupportDynamic ENUM_SUPPORT = @@ -61,9 +68,19 @@ public static String valueOf(StatusEnum e) { /** The reason that the system used to determine who answered the call. */ public class ReasonEnum extends EnumDynamic { + /** If the greeting is too long, this could be indicative of an answering machine. */ public static final ReasonEnum LONGGREETING = new ReasonEnum("longgreeting"); + + /** + * If there is an initial silence after the call is answered before the greeting starts, this + * could be indicative of an answering machine. + */ public static final ReasonEnum INITIALSILENCE = new ReasonEnum("initialsilence"); + + /** If there is a beep in the call, this could be indicative of an answering machine. */ public static final ReasonEnum BEEP = new ReasonEnum("beep"); + + /** This is displayed if a reason isn't available. */ public static final ReasonEnum N_A = new ReasonEnum("n/a"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/DisconnectedCallEvent.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/DisconnectedCallEvent.java index 9348440cc..fd46ccf78 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/DisconnectedCallEvent.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/DisconnectedCallEvent.java @@ -50,6 +50,7 @@ public interface DisconnectedCallEvent extends VoiceSinchEvent, VoiceCallSinchEv /** Must have the value dice. */ public class SinchEventType extends EnumDynamic { + /** A Disconnected Call Event. */ public static final SinchEventType DICE = new SinchEventType("dice"); private static final EnumSupportDynamic ENUM_SUPPORT = @@ -74,20 +75,49 @@ public static String valueOf(SinchEventType e) { /** The reason the call was disconnected. */ public class ReasonEnum extends EnumDynamic { + /** Not applicable. */ public static final ReasonEnum N_A = new ReasonEnum("N/A"); + + /** Call successfully connected. */ public static final ReasonEnum ESTABLISHED = new ReasonEnum("ESTABLISHED"); + + /** Call was answered by another instance of the same user. */ public static final ReasonEnum OTHERPEERANSWERED = new ReasonEnum("OTHERPEERANSWERED"); + + /** The call exceeded the configured timeout. */ public static final ReasonEnum TIMEOUT = new ReasonEnum("TIMEOUT"); + + /** The caller hung up the call. */ public static final ReasonEnum CALLERHANGUP = new ReasonEnum("CALLERHANGUP"); + + /** The callee hung up the call. */ public static final ReasonEnum CALLEEHANGUP = new ReasonEnum("CALLEEHANGUP"); + + /** The call was blocked. */ public static final ReasonEnum BLOCKED = new ReasonEnum("BLOCKED"); + + /** The call manager hung up the call. */ public static final ReasonEnum MANAGERHANGUP = new ReasonEnum("MANAGERHANGUP"); + + /** No sufficient credit to make the call. */ public static final ReasonEnum NOCREDITPARTNER = new ReasonEnum("NOCREDITPARTNER"); + + /** The call was disconnected due to a network related issue. */ public static final ReasonEnum CLIENTNETWORK = new ReasonEnum("CLIENTNETWORK"); + + /** No routes available to connect the call. */ public static final ReasonEnum CONGESTION = new ReasonEnum("CONGESTION"); + + /** A non-specified error ended the call. */ public static final ReasonEnum GENERALERROR = new ReasonEnum("GENERALERROR"); + + /** The call was canceled. */ public static final ReasonEnum CANCEL = new ReasonEnum("CANCEL"); + + /** The user was not found. */ public static final ReasonEnum USERNOTFOUND = new ReasonEnum("USERNOTFOUND"); + + /** An error with the callback ended the call. */ public static final ReasonEnum CALLBACKERROR = new ReasonEnum("CALLBACKERROR"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/MenuResult.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/MenuResult.java index d191d02c3..3bc95f9ce 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/MenuResult.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/MenuResult.java @@ -29,11 +29,22 @@ public interface MenuResult { /** The type of information that's returned. */ public class TypeEnum extends EnumDynamic { + /** Returned if there's an error with the input. */ public static final TypeEnum ERROR = new TypeEnum("error"); + + /** Returned when the event has been triggered from a return command. */ public static final TypeEnum RETURN = new TypeEnum("return"); + + /** Returned when the event has been triggered from collecting DTMF digits. */ public static final TypeEnum SEQUENCE = new TypeEnum("sequence"); + + /** Returned when the timeout period has elapsed. */ public static final TypeEnum TIMEOUT = new TypeEnum("timeout"); + + /** Returned when the call is hung up. */ public static final TypeEnum HANGUP = new TypeEnum("hangup"); + + /** Returned when the value of the input is invalid. */ public static final TypeEnum INVALIDINPUT = new TypeEnum("invalidinput"); private static final EnumSupportDynamic ENUM_SUPPORT = @@ -75,7 +86,10 @@ public static String valueOf(TypeEnum e) { /** The type of input received. */ public class InputMethodEnum extends EnumDynamic { + /** The input is key presses of specified digits. */ public static final InputMethodEnum DTMF = new InputMethodEnum("dtmf"); + + /** The input is voice answers. */ public static final InputMethodEnum VOICE = new InputMethodEnum("voice"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/NotificationEvent.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/NotificationEvent.java index a8478be54..285e9a451 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/NotificationEvent.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/NotificationEvent.java @@ -22,6 +22,7 @@ public interface NotificationEvent extends VoiceSinchEvent { /** Must have the value notify. */ public class SinchEventType extends EnumDynamic { + /** A Notification Event. */ public static final SinchEventType NOTIFY = new SinchEventType("notify"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/PromptInputEvent.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/PromptInputEvent.java index dba65be3f..ead08c24c 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/PromptInputEvent.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/sinchevents/PromptInputEvent.java @@ -23,6 +23,7 @@ public interface PromptInputEvent extends VoiceSinchEvent { /** Must have the value pie. */ public class SinchEventType extends EnumDynamic { + /** A Prompt Input Event. */ public static final SinchEventType PIE = new SinchEventType("pie"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectConference.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectConference.java index 5f3504c86..6a844d7da 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectConference.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectConference.java @@ -29,6 +29,7 @@ public interface SvamlActionConnectConference /** The name property. Must have the value connectConf. */ public class NameEnum extends EnumDynamic { + /** The connectConf action. */ public static final NameEnum CONNECT_CONF = new NameEnum("connectConf"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectMxp.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectMxp.java index d24670e6f..70a0c7391 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectMxp.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectMxp.java @@ -29,6 +29,7 @@ public interface SvamlActionConnectMxp /** The name property. Must have the value connectMxp. */ public class NameEnum extends EnumDynamic { + /** The connectMxp action. */ public static final NameEnum CONNECT_MXP = new NameEnum("connectMxp"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectPstn.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectPstn.java index dd74ec6a9..4cd9bcfb3 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectPstn.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectPstn.java @@ -28,6 +28,7 @@ public interface SvamlActionConnectPstn /** The name property. Must have the value connectPstn. */ public class NameEnum extends EnumDynamic { + /** The connectPstn action. */ public static final NameEnum CONNECT_PSTN = new NameEnum("connectPstn"); private static final EnumSupportDynamic ENUM_SUPPORT = @@ -117,44 +118,121 @@ public static String valueOf(NameEnum e) { /** The locale's tone to play while ringing. */ public class IndicationsEnum extends EnumDynamic { + /** Austria */ public static final IndicationsEnum AT = new IndicationsEnum("at"); + + /** Australia */ public static final IndicationsEnum AU = new IndicationsEnum("au"); + + /** Bulgaria */ public static final IndicationsEnum BG = new IndicationsEnum("bg"); + + /** Brazil */ public static final IndicationsEnum BR = new IndicationsEnum("br"); + + /** Belgium */ public static final IndicationsEnum BE = new IndicationsEnum("be"); + + /** Switzerland */ public static final IndicationsEnum CH = new IndicationsEnum("ch"); + + /** Chile */ public static final IndicationsEnum CL = new IndicationsEnum("cl"); + + /** China */ public static final IndicationsEnum CN = new IndicationsEnum("cn"); + + /** Czech Republic */ public static final IndicationsEnum CZ = new IndicationsEnum("cz"); + + /** Germany */ public static final IndicationsEnum DE = new IndicationsEnum("de"); + + /** Denmark */ public static final IndicationsEnum DK = new IndicationsEnum("dk"); + + /** Estonia */ public static final IndicationsEnum EE = new IndicationsEnum("ee"); + + /** Spain */ public static final IndicationsEnum ES = new IndicationsEnum("es"); + + /** Finland */ public static final IndicationsEnum FI = new IndicationsEnum("fi"); + + /** France */ public static final IndicationsEnum FR = new IndicationsEnum("fr"); + + /** Greece */ public static final IndicationsEnum GR = new IndicationsEnum("gr"); + + /** Hungary */ public static final IndicationsEnum HU = new IndicationsEnum("hu"); + + /** Israel */ public static final IndicationsEnum IL = new IndicationsEnum("il"); + + /** India */ public static final IndicationsEnum IN = new IndicationsEnum("in"); + + /** Italy */ public static final IndicationsEnum IT = new IndicationsEnum("it"); + + /** Lithuania */ public static final IndicationsEnum LT = new IndicationsEnum("lt"); + + /** Japan */ public static final IndicationsEnum JP = new IndicationsEnum("jp"); + + /** Mexico */ public static final IndicationsEnum MX = new IndicationsEnum("mx"); + + /** Malaysia */ public static final IndicationsEnum MY = new IndicationsEnum("my"); + + /** Netherlands */ public static final IndicationsEnum NL = new IndicationsEnum("nl"); + + /** Norway */ public static final IndicationsEnum FALSE = new IndicationsEnum("false"); + + /** New Zealand */ public static final IndicationsEnum NZ = new IndicationsEnum("nz"); + + /** Philippines */ public static final IndicationsEnum PH = new IndicationsEnum("ph"); + + /** Poland */ public static final IndicationsEnum PL = new IndicationsEnum("pl"); + + /** Portugal */ public static final IndicationsEnum PT = new IndicationsEnum("pt"); + + /** Russia */ public static final IndicationsEnum RU = new IndicationsEnum("ru"); + + /** Sweden */ public static final IndicationsEnum SE = new IndicationsEnum("se"); + + /** Singapore */ public static final IndicationsEnum SG = new IndicationsEnum("sg"); + + /** Thailand */ public static final IndicationsEnum TH = new IndicationsEnum("th"); + + /** United Kingdom */ public static final IndicationsEnum UK = new IndicationsEnum("uk"); + + /** United States */ public static final IndicationsEnum US = new IndicationsEnum("us"); + + /** Taiwan */ public static final IndicationsEnum TW = new IndicationsEnum("tw"); + + /** Venezuela */ public static final IndicationsEnum VE = new IndicationsEnum("ve"); + + /** South Africa */ public static final IndicationsEnum ZA = new IndicationsEnum("za"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectSip.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectSip.java index 4258a4fbb..c58423354 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectSip.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectSip.java @@ -30,6 +30,7 @@ public interface SvamlActionConnectSip /** The name property. Must have the value connectSip. */ public class NameEnum extends EnumDynamic { + /** The connectSip action. */ public static final NameEnum CONNECT_SIP = new NameEnum("connectSip"); private static final EnumSupportDynamic ENUM_SUPPORT = @@ -80,8 +81,13 @@ public static String valueOf(NameEnum e) { /** An optional parameter to specify the SIP transport protocol. If unspecified, UDP is used. */ public class TransportEnum extends EnumDynamic { + /** User Datagram Protocol */ public static final TransportEnum UDP = new TransportEnum("UDP"); + + /** Transmission Control Protocol */ public static final TransportEnum TCP = new TransportEnum("TCP"); + + /** Transport Layer Security */ public static final TransportEnum TLS = new TransportEnum("TLS"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectStream.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectStream.java index 45eded7ea..1de4e9e53 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectStream.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionConnectStream.java @@ -29,6 +29,7 @@ public interface SvamlActionConnectStream /** The name property. Must have the value connectStream. */ public class NameEnum extends EnumDynamic { + /** The connectStream action. */ public static final NameEnum CONNECT_STREAM = new NameEnum("connectStream"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionContinue.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionContinue.java index 8c9ed6c40..a1c101f4c 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionContinue.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionContinue.java @@ -31,6 +31,7 @@ public interface SvamlActionContinue /** The name property. Must have the value continue. */ public class NameEnum extends EnumDynamic { + /** The continue action. */ public static final NameEnum CONTINUE = new NameEnum("continue"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionHangup.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionHangup.java index 9433b1099..74083a985 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionHangup.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionHangup.java @@ -33,6 +33,7 @@ public interface SvamlActionHangup /** The name property. Must have the value hangup. */ public class NameEnum extends EnumDynamic { + /** The hangup action. */ public static final NameEnum HANGUP = new NameEnum("hangup"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionPark.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionPark.java index 74161aa2a..b8d87b61a 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionPark.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionPark.java @@ -29,6 +29,7 @@ public interface SvamlActionPark /** The name property. Must have the value park. */ public class NameEnum extends EnumDynamic { + /** The park action. */ public static final NameEnum PARK = new NameEnum("park"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionRunMenu.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionRunMenu.java index 7bb7eeade..a0dd0f94e 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionRunMenu.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/action/SvamlActionRunMenu.java @@ -36,6 +36,7 @@ public interface SvamlActionRunMenu /** The name property. Must have the value runMenu. */ public class NameEnum extends EnumDynamic { + /** The runMenu action. */ public static final NameEnum RUN_MENU = new NameEnum("runMenu"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionAnswer.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionAnswer.java index cbd97d136..47faf4c44 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionAnswer.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionAnswer.java @@ -26,6 +26,7 @@ public interface SvamlInstructionAnswer /** The name property. Must have the value answer. */ public class NameEnum extends EnumDynamic { + /** The answer instruction. */ public static final NameEnum ANSWER = new NameEnum("answer"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionPlayFiles.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionPlayFiles.java index d2349258e..6255fa2e8 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionPlayFiles.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionPlayFiles.java @@ -27,6 +27,7 @@ public interface SvamlInstructionPlayFiles /** The name property. Must have the value playFiles. */ public class NameEnum extends EnumDynamic { + /** The playFiles instruction. */ public static final NameEnum PLAY_FILES = new NameEnum("playFiles"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSay.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSay.java index bf9aec294..83a2bfc94 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSay.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSay.java @@ -26,6 +26,7 @@ public interface SvamlInstructionSay /** The name property. Must have the value say. */ public class NameEnum extends EnumDynamic { + /** The say instruction. */ public static final NameEnum SAY = new NameEnum("say"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSendDtmf.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSendDtmf.java index 9545e6215..15f6dfddc 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSendDtmf.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSendDtmf.java @@ -23,6 +23,7 @@ public interface SvamlInstructionSendDtmf /** The name property. Must have the value sendDtmf. */ public class NameEnum extends EnumDynamic { + /** The sendDtmf instruction. */ public static final NameEnum SEND_DTMF = new NameEnum("sendDtmf"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSetCookie.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSetCookie.java index a81f952c4..7bb9c702f 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSetCookie.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionSetCookie.java @@ -23,6 +23,7 @@ public interface SvamlInstructionSetCookie /** The name property. Must have the value setCookie. */ public class NameEnum extends EnumDynamic { + /** The setCookie instruction. */ public static final NameEnum SET_COOKIE = new NameEnum("setCookie"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionStartRecording.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionStartRecording.java index 565ed3026..3b39256a8 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionStartRecording.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionStartRecording.java @@ -23,6 +23,7 @@ public interface SvamlInstructionStartRecording /** The name property. Must have the value startRecording. */ public class NameEnum extends EnumDynamic { + /** The startRecording instruction. */ public static final NameEnum START_RECORDING = new NameEnum("startRecording"); private static final EnumSupportDynamic ENUM_SUPPORT = diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionStopRecording.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionStopRecording.java index 33f328d43..d11059006 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionStopRecording.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v1/svaml/instruction/SvamlInstructionStopRecording.java @@ -27,6 +27,7 @@ public interface SvamlInstructionStopRecording /** The name property. Must have the value stopRecording. */ public class NameEnum extends EnumDynamic { + /** The stopRecording instruction. */ public static final NameEnum STOP_RECORDING = new NameEnum("stopRecording"); private static final EnumSupportDynamic ENUM_SUPPORT = From f3376486af6e0829e656f3dd80aad9c8e54b6944 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 17 Sep 2026 10:09:14 +0200 Subject: [PATCH 14/21] fix(deps): bump httpclient5 5.6.2 -> 5.6.3 (CVE-2026-64607) --- CHANGELOG.md | 1 + pom.xml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d3e6c665..da8f0ef17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ### Build & CI - **[tech]** Build `examples` across a Java version matrix (`21`, `25`) in GitHub Actions, replacing the single Java 21 build. +- **[dependency]** Bump `httpclient5.version` to `5.6.3` to solve a connection leak leading to connection pool exhaustion in `httpclient5` (CVE-2026-64607 and GHSA-hjcp-jmpx-g3qm). ### Tests - **[test]** Fix HttpClient multipart test when returned boundary string contains '--' sequence diff --git a/pom.xml b/pom.xml index 1610974c3..4a3ea024f 100644 --- a/pom.xml +++ b/pom.xml @@ -61,7 +61,7 @@ 2.21.5 - 5.6.2 + 5.6.3 5.23.0 From 790c142d8f845cea12954c051a3afa03a32299aa Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 17 Sep 2026 10:12:22 +0200 Subject: [PATCH 15/21] CHANGELOG uodated: add Voice dice entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da8f0ef17..1d4d8c2c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ All notable changes to the **Sinch Java SDK** are documented in this file. - `getInternalFailureCode()` - Use their dedicated `ActiveNumberSinchEvent` and `NumberOrderSinchEvent` fields +### Voice +- **[feature]** Support `dice` field for `customCallout`: the callback URL receiving the DiCE event when the call is disconnected + ### Build & CI - **[tech]** Build `examples` across a Java version matrix (`21`, `25`) in GitHub Actions, replacing the single Java 21 build. - **[dependency]** Bump `httpclient5.version` to `5.6.3` to solve a connection leak leading to connection pool exhaustion in `httpclient5` (CVE-2026-64607 and GHSA-hjcp-jmpx-g3qm). From 181bbd1f4ef9871a63d9bd66d83f6af4cd4e1a3a Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 17 Sep 2026 14:55:03 +0200 Subject: [PATCH 16/21] Fixed path for verification e2e tests --- client/src/test/java/com/sinch/sdk/e2e/Config.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/test/java/com/sinch/sdk/e2e/Config.java b/client/src/test/java/com/sinch/sdk/e2e/Config.java index 80d60568c..dd7c2ab44 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/Config.java +++ b/client/src/test/java/com/sinch/sdk/e2e/Config.java @@ -39,7 +39,7 @@ public class Config { public static final String SMS_HOST_NAME = MOCK_SERVER_URL + "/sms"; - public static final String VERIFICATION_HOST_NAME = MOCK_SERVER_URL; + public static final String VERIFICATION_HOST_NAME = MOCK_SERVER_URL + "/verification"; public static final String VERIFICATION_WEBHOOKS_HOST_NAME = MOCK_SERVER_URL + "/verification"; public static final String NUMBER_LOOKUP_HOST_NAME = MOCK_SERVER_URL + "/number-lookup"; From 4d1b5bac8cbaa30a225c4c0b385dc08d9795f76b Mon Sep 17 00:00:00 2001 From: git Date: Thu, 17 Sep 2026 13:11:14 +0000 Subject: [PATCH 17/21] build (release): Bump version to 2.2.0 for sources --- client/src/main/com/sinch/sdk/SDK.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/com/sinch/sdk/SDK.java b/client/src/main/com/sinch/sdk/SDK.java index d01900177..8bac5446c 100644 --- a/client/src/main/com/sinch/sdk/SDK.java +++ b/client/src/main/com/sinch/sdk/SDK.java @@ -3,6 +3,6 @@ public class SDK { public static final String NAME = "Sinch Java SDK"; - public static final String VERSION = "2.2.0-dev"; + public static final String VERSION = "2.2.0"; public static final String AUXILIARY_FLAG = ""; } From e308af823874b8db4ad5dbe37fd88e8710d05ed2 Mon Sep 17 00:00:00 2001 From: git Date: Thu, 17 Sep 2026 13:13:18 +0000 Subject: [PATCH 18/21] [release] Set release & tag: 2.2.0 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 4a3ea024f..5478fa015 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.sinch.sdk sinch-sdk-java - 2.2.0-SNAPSHOT + 2.2.0 Sinch Java SDK @@ -37,7 +37,7 @@ https://github.com/sinch/sinch-sdk-java.git scm:git:${project.scm.url} scm:git:${project.scm.url} - HEAD + v2.2.0 From add010c45b49c74ca6d61b883ffbbd9ac08480ab Mon Sep 17 00:00:00 2001 From: git Date: Thu, 17 Sep 2026 13:13:20 +0000 Subject: [PATCH 19/21] [release] Set next version: 2.3.0-SNAPSHOT --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 5478fa015..29cd923ff 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.sinch.sdk sinch-sdk-java - 2.2.0 + 2.3.0-SNAPSHOT Sinch Java SDK @@ -37,7 +37,7 @@ https://github.com/sinch/sinch-sdk-java.git scm:git:${project.scm.url} scm:git:${project.scm.url} - v2.2.0 + HEAD From 1c6dd016d9a2c5795326ff6530955eb5f3e37686 Mon Sep 17 00:00:00 2001 From: git Date: Thu, 17 Sep 2026 13:15:22 +0000 Subject: [PATCH 20/21] build (release): Set next version to 2.3.0-dev for sources --- client/src/main/com/sinch/sdk/SDK.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/com/sinch/sdk/SDK.java b/client/src/main/com/sinch/sdk/SDK.java index 8bac5446c..68682caf9 100644 --- a/client/src/main/com/sinch/sdk/SDK.java +++ b/client/src/main/com/sinch/sdk/SDK.java @@ -3,6 +3,6 @@ public class SDK { public static final String NAME = "Sinch Java SDK"; - public static final String VERSION = "2.2.0"; + public static final String VERSION = "2.3.0-dev"; public static final String AUXILIARY_FLAG = ""; } From 17f90917f4c4e2eabc44ac8e1206e5a0c9eec4f8 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 17 Sep 2026 16:09:25 +0200 Subject: [PATCH 21/21] CHANGELOG updated: set v2.2.0 release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d4d8c2c5..a735ee66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. > - `[tech]` — technical improvement --- -## v2.2.0 - unreleased +## v2.2.0 - 2026-09-17 ### SDK - **[feature]** Default Apache HttpClient retry policy disabled in favor of a dedicated SDK implementation: