Skip to content

feat: Native failover in Messages API #586

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).

# [9.3.0] - 2025-05-07
- Added support for native failover in Messages API

# [9.2.0] - 2025-04-30
- Added support for setting additional request headers in `HttpConfig`
- Allow setting `HttpClient` and `HttpConfig` on `HttpWrapper`
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Add the following to your `build.gradle` or `build.gradle.kts` file:

```groovy
dependencies {
implementation("com.vonage:server-sdk:9.2.0")
implementation("com.vonage:server-sdk:9.3.0")
}
```

Expand All @@ -85,7 +85,7 @@ Add the following to the `<dependencies>` section of your `pom.xml` file:
<dependency>
<groupId>com.vonage</groupId>
<artifactId>server-sdk</artifactId>
<version>9.2.0</version>
<version>9.3.0</version>
</dependency>
```

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<groupId>com.vonage</groupId>
<artifactId>server-sdk</artifactId>
<version>9.2.0</version>
<version>9.3.0</version>

<name>Vonage Java Server SDK</name>
<description>Java client for Vonage APIs</description>
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/vonage/client/HttpWrapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
public class HttpWrapper {
private static final String
CLIENT_NAME = "vonage-java-sdk",
CLIENT_VERSION = "9.2.0",
CLIENT_VERSION = "9.3.0",
JAVA_VERSION = System.getProperty("java.version"),
USER_AGENT = String.format("%s/%s java/%s", CLIENT_NAME, CLIENT_VERSION, JAVA_VERSION);

Expand Down
39 changes: 36 additions & 3 deletions src/main/java/com/vonage/client/messages/MessageRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@
import com.vonage.client.common.MessageType;
import com.vonage.client.messages.internal.MessagePayload;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.*;

/**
* Abstract base class of all Messages sent via the Messages v1 API. All subclasses follow a
Expand All @@ -43,6 +41,7 @@ public abstract class MessageRequest extends JsonableBaseObject {
final String clientRef;
final URI webhookUrl;
final MessagesVersion webhookVersion;
final List<MessageRequest> failover;
protected final Integer ttl;
final String text;
protected final Map<String, Object> custom;
Expand Down Expand Up @@ -71,6 +70,7 @@ protected MessageRequest(Builder<?, ?> builder, Channel channel, MessageType mes
clientRef = validateClientReference(builder.clientRef);
webhookUrl = builder.webhookUrl;
webhookVersion = builder.webhookVersion;
failover = builder.failover;

MessagePayload media = null;
Map<String, Object> custom = null;
Expand Down Expand Up @@ -180,6 +180,11 @@ public MessagesVersion getWebhookVersion() {
return webhookVersion;
}

@JsonProperty("failover")
public List<MessageRequest> getFailover() {
return failover;
}

@JsonProperty("ttl")
protected Integer getTtl() {
return ttl;
Expand Down Expand Up @@ -210,6 +215,7 @@ public abstract static class Builder<M extends MessageRequest, B extends Builder
private String from, to, clientRef, text, url, caption, name;
private URI webhookUrl;
private MessagesVersion webhookVersion;
private List<MessageRequest> failover;
private Integer ttl;
private Map<String, Object> custom;

Expand Down Expand Up @@ -381,6 +387,33 @@ protected B name(String name) {
return (B) this;
}

/**
* (OPTIONAL)
* Sets the failover messages to be sent if the primary message fails.
*
* @param failover The failover message(s).
* @return This builder.
*
* @since 9.3.0
*/
public B failover(MessageRequest... failover) {
return failover(Arrays.asList(failover));
}

/**
* (OPTIONAL)
* Sets the failover messages to be sent if the primary message fails.
*
* @param failover The list of failover messages.
* @return This builder.
*
* @since 9.3.0
*/
public B failover(List<MessageRequest> failover) {
this.failover = failover;
return (B) this;
}

/**
* Builds the MessageRequest.
*
Expand Down
15 changes: 14 additions & 1 deletion src/main/java/com/vonage/client/messages/MessageResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
* the returned response (HTTP 202 payload) is always the same format.
*/
public class MessageResponse extends JsonableBaseObject {
protected UUID messageUuid;
private UUID messageUuid;
private String workflowId;

/**
* Protected to prevent users from explicitly creating this object.
Expand All @@ -41,4 +42,16 @@ protected MessageResponse() {
public UUID getMessageUuid() {
return messageUuid;
}

/**
* Returns the workflow ID of the message that was sent to track the failover (if applicable).
*
* @return The failover workflow ID, or {@code null} if absent / not applicable.
*
* @since 9.3.0
*/
@JsonProperty("workflow_id")
public String getWorkflowId() {
return workflowId;
}
}
50 changes: 50 additions & 0 deletions src/main/java/com/vonage/client/messages/MessageStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ public String toString() {
}
}

/**
* Represents the {@code workflow} object in the status webhook for native failover.
*
* @since 9.3.0
*/
public static final class Workflow extends JsonableBaseObject {
@JsonProperty("id") String id;
@JsonProperty("item_number") Integer itemNumber;
@JsonProperty("items_total") @JsonAlias("total_items") Integer totalItems;

/**
* ID of the workflow.
*
* @return The workflow type as a string.
*/
public String getId() {
return id;
}

/**
* The message number (sequence) in the workflow.
*
* @return The index of this message in the workflow as integer.
*/
public Integer getItemNumber() {
return itemNumber;
}

/**
* Total number of messages in this workflow.
*
* @return The number of messages (including failover) in the workflow as integer.
*/
public Integer getTotalItems() {
return totalItems;
}
}

/**
* Describes the error that was encountered when sending the message.
*/
Expand Down Expand Up @@ -174,6 +212,7 @@ protected MessageStatus() {
@JsonProperty("channel") protected Channel channel;
@JsonProperty("client_ref") protected String clientRef;
@JsonProperty("error") protected Error error;
@JsonProperty("workflow") protected Workflow workflow;
@JsonProperty("usage") protected Usage usage;

@JsonProperty("destination") private Destination destination;
Expand Down Expand Up @@ -254,6 +293,17 @@ public Error getError() {
return error;
}

/**
* If the message was sent with a failover workflow, the details of this will be returned here.
*
* @return The workflow object, or {@code null} if absent / not applicable.
*
* @since 9.3.0
*/
public Workflow getWorkflow() {
return workflow;
}

/**
* Describes the cost of the message that was sent.
*
Expand Down
6 changes: 2 additions & 4 deletions src/test/java/com/vonage/client/TestUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,11 @@ public static HttpWrapper httpWrapperWithAllAuthMethods() {
}


// TODO make package-private after removing Meetings
public static CloseableHttpClient stubHttpClient(int statusCode) throws Exception {
static CloseableHttpClient stubHttpClient(int statusCode) throws Exception {
return stubHttpClient(statusCode, "");
}

// TODO make package-private after removing Meetings
public static CloseableHttpClient stubHttpClient(int statusCode, String content, String... additionalReturns) throws Exception {
static CloseableHttpClient stubHttpClient(int statusCode, String content, String... additionalReturns) throws Exception {
CloseableHttpClient result = mock(CloseableHttpClient.class);

CloseableHttpResponse response = mock(CloseableHttpResponse.class);
Expand Down
21 changes: 16 additions & 5 deletions src/test/java/com/vonage/client/messages/MessageRequestTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,21 @@ private ConcreteMessageRequest(Builder builder) {
}

@Test
public void testSerializeAllFields() {
public void testSerializeAllFieldsWithFailover() {
MessageRequest smr = ConcreteMessageRequest.builder(MessageType.VIDEO, Channel.MMS)
.from("447900000009").to("12002009000")
.url("https://example.com/video.mp4")
.clientRef("<40 character string")
.webhookUrl("https://example.com/status")
.webhookVersion(MessagesVersion.V1).build();
.webhookVersion(MessagesVersion.V1)
.failover(ConcreteMessageRequest.builder(MessageType.IMAGE, Channel.RCS)
.from("12002009001").to("44790000002")
.url("https://example.org/image.jpg").build(),
ConcreteMessageRequest.builder(MessageType.TEXT, Channel.SMS)
.from("SenderID").to("44790000004")
.text("Fallback text message").build()
)
.build();

String generatedJson = smr.toJson();
assertTrue(generatedJson.contains("\"client_ref\":\"<40 character string\""));
Expand All @@ -64,6 +72,11 @@ public void testSerializeAllFields() {
assertTrue(generatedJson.contains("\"to\":\"12002009000\""));
assertTrue(generatedJson.contains("\"channel\":\"mms\""));
assertTrue(generatedJson.contains("\"message_type\":\"video\""));
assertTrue(generatedJson.contains("\"failover\":[" +
"{\"message_type\":\"image\",\"channel\":\"rcs\",\"from\":\"12002009001\",\"to\":\"44790000002\"}," +
"{\"message_type\":\"text\",\"channel\":\"sms\",\"from\":\"SenderID\",\"to\":\"44790000004\"," +
"\"text\":\"Fallback text message\"}]"
));
assertFalse(generatedJson.contains("https://example.com/video.mp4"));
}

Expand Down Expand Up @@ -200,9 +213,7 @@ public void testConstructNoChannel() {
@Test
public void testConstructLongClientRef() {
StringBuilder clientRef = new StringBuilder(41);
for (int i = 0; i < 99; i++) {
clientRef.append('c');
}
clientRef.append("c".repeat(99));

ConcreteMessageRequest.Builder builder = ConcreteMessageRequest
.builder(MessageType.FILE, Channel.RCS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ public class MessageResponseTest {
@Test
public void testConstructFromValidJson() {
UUID uuid = UUID.randomUUID();
MessageResponse response = Jsonable.fromJson("{\"message_uuid\":\""+uuid+"\"}");
String workflowId = "3TcNjguphQTKshBPRJUVYx6sREpqvhgghJza6sWMk9ztEQQdiW9vwGG";
MessageResponse response = Jsonable.fromJson("{\"message_uuid\":\""+uuid+"\",\"workflow_id\":\""+workflowId+"\"}");
assertEquals(uuid, response.getMessageUuid());
assertEquals(workflowId, response.getWorkflowId());
String toString = response.toString();
assertTrue(toString.contains("MessageResponse"));
assertTrue(toString.contains(uuid.toString()));
Expand All @@ -39,6 +41,7 @@ public void testConstructFromValidJson() {
public void testConstructFromEmptyJson() {
MessageResponse response = Jsonable.fromJson("{}");
assertNull(response.getMessageUuid());
assertNull(response.getWorkflowId());
TestUtils.testJsonableBaseObject(response);
}

Expand Down
20 changes: 20 additions & 0 deletions src/test/java/com/vonage/client/messages/MessageStatusTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public void testSerdesAllFields() {
error.title = String.valueOf(title);
error.detail = detail;
error.instance = instance;
MessageStatus.Workflow workflow = new MessageStatus.Workflow();
workflow.id = "1001";
workflow.itemNumber = 2;
workflow.totalItems = 3;

MessageStatus.Usage usage = new MessageStatus.Usage();
usage.price = price;
usage.currency = currency;
Expand Down Expand Up @@ -78,6 +83,11 @@ public void testSerdesAllFields() {
" \"detail\": \""+detail+"\",\n" +
" \"instance\": \""+instance+"\"\n" +
" },\n" +
" \"workflow\": {\n" +
" \"id\": \"1001\",\n" +
" \"item_number\": \"2\",\n" +
" \"total_items\": \"3\"\n" +
" },\n" +
" \"usage\": {\n" +
" \"currency\": \""+currency+"\",\n" +
" \"price\": \""+price+"\"\n" +
Expand Down Expand Up @@ -111,6 +121,8 @@ public void testSerdesAllFields() {
assertEquals("sms", channel.toString());
assertEquals(error, ms.getError());
assertEquals(error.toString(), ms.getError().toString());
assertEquals(workflow, ms.getWorkflow());
assertEquals(workflow.toString(), ms.getWorkflow().toString());
assertEquals(usage, ms.getUsage());
assertEquals(usage.toString(), ms.getUsage().toString());
assertEquals(networkCode, ms.getDestinationNetworkCode());
Expand Down Expand Up @@ -148,6 +160,7 @@ public void testSerdesRequiredFields() {
assertEquals("undeliverable", status.toString());
assertEquals(channel, ms.getChannel());
assertEquals("mms", channel.toString());
assertNull(ms.getWorkflow());
assertNull(ms.getSmsTotalCount());
assertNull(ms.getDestinationNetworkCode());
assertNull(ms.getWhatsappConversationType());
Expand All @@ -173,6 +186,7 @@ public void testDeserializeUnknownProperties() {
" \"currency\": \"EUR\",\n" +
" \"price\": \"0.0333\"\n" +
" },\n" +
" \"workflow\": {},\n" +
" \"client_ref\": \"string\",\n" +
" \"channel\": \"whatsapp\",\n" +
" \"wubwub\": {\n" +
Expand All @@ -187,6 +201,12 @@ public void testDeserializeUnknownProperties() {
MessageStatus ms = MessageStatus.fromJson(json);
testJsonableBaseObject(ms);

MessageStatus.Workflow workflow = ms.getWorkflow();
assertNotNull(workflow);
assertNull(workflow.getId());
assertNull(workflow.getItemNumber());
assertNull(workflow.getTotalItems());

Map<String, ?> unknown = ms.getAdditionalProperties();
assertNotNull(unknown);
assertEquals(1, unknown.size());
Expand Down