Skip to content

Commit b1d3301

Browse files
authored
feat: Add support for custom HTTP requests (#581)
* feat: Add support for custom HTTP requests * Add documentation * Fix JsonableMap * Add CustomClientTest * Handle Map/JSON in DynamicEndpoint * Update cusotm requests README doc * Bump JUnit version * Accept all auth methods * Add note on response parsing * Updated changelog
1 parent ef2d5a9 commit b1d3301

File tree

11 files changed

+500
-13
lines changed

11 files changed

+500
-13
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
All notable changes to this project will be documented in this file.
33
This project adheres to [Semantic Versioning](http://semver.org/).
44

5+
# [9.1.0] - 2025-04-22
6+
- Added custom HTTP requests support via `CustomClient` (see [README](README.md#custom-requests) for details)
7+
58
# [9.0.0] - 2025-04-08
69
- Removed deprecations (classes, methods, constructors, packages etc.)
710
- Removed Meetings, Proactive Connect and Number Insight v2 APIs

README.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Add the following to your `build.gradle` or `build.gradle.kts` file:
7474

7575
```groovy
7676
dependencies {
77-
implementation("com.vonage:server-sdk:9.0.0")
77+
implementation("com.vonage:server-sdk:9.1.0")
7878
}
7979
```
8080

@@ -85,7 +85,7 @@ Add the following to the `<dependencies>` section of your `pom.xml` file:
8585
<dependency>
8686
<groupId>com.vonage</groupId>
8787
<artifactId>server-sdk</artifactId>
88-
<version>9.0.0</version>
88+
<version>9.1.0</version>
8989
</dependency>
9090
```
9191

@@ -112,6 +112,83 @@ the dependency co-ordinates and add `mavenLocal()` to the `repositories` block i
112112
* For a searchable list of code snippets examples, see [**SNIPPETS.md**](https://github.com/Vonage/vonage-java-code-snippets/blob/main/SNIPPETS.md).
113113
* For Video API usage instructions, see [the guide on our developer portal](https://developer.vonage.com/en/video/server-sdks/java).
114114

115+
### Custom Requests
116+
Beginning with v9.1.0, you can now make customisable requests to any Vonage API endpoint using the `CustomClient` class,
117+
obtained from the `VonageClient#getCustomClient()` method. This will take care of auth and serialisation for you.
118+
You can use existing data models from the SDK or create your own by extending `com.vonage.client.JsonableBaseObject`
119+
or implementing the `com.vonage.client.Jsonable` interface. For example, you can check your account balance using
120+
the following code, which will send a `get` request to the specified URL and return a `BalanceResponse` object:
121+
122+
```java
123+
BalanceResponse response = client.getCustomClient().get("https://rest.nexmo.com/account/get-balance");
124+
```
125+
126+
You can also parse the response into a `Map<String, ?>` which represents the JSON response body as a tree like so:
127+
128+
```java
129+
Map<String, ?> response = client.getCustomClient().get("https://api-eu.vonage.com/v3/media?order=ascending&page_size=50");
130+
```
131+
132+
The same applies for `POST`, `PUT` and `PATCH` requests when sending data.
133+
You can mix and match between `java.util.Map` and `com.vonage.client.Jsonable` interfaces for request and response bodies.
134+
For example, to create an application, you can use any of the following (all are equivalent):
135+
136+
#### Map request, Map response
137+
```java
138+
Map<String, ?> response = client.getCustomClient().post(
139+
"https://api.nexmo.com/v2/applications",
140+
Map.of("name", "Demo Application")
141+
);
142+
```
143+
144+
#### Map request, Jsonable response
145+
```java
146+
Application response = client.getCustomClient().post(
147+
"https://api.nexmo.com/v2/applications",
148+
Map.of("name", "Demo Application")
149+
);
150+
```
151+
152+
#### Jsonable request, Map response
153+
```java
154+
Map<String, ?> response = client.getCustomClient().post(
155+
"https://api.nexmo.com/v2/applications",
156+
Application.builder().name("Demo Application").build()
157+
);
158+
```
159+
160+
#### Jsonable request, Jsonable response
161+
```java
162+
Application response = client.getCustomClient().post(
163+
"https://api.nexmo.com/v2/applications",
164+
Application.builder().name("Demo Application").build()
165+
);
166+
```
167+
168+
#### Supported response types
169+
The `<R>` parameter in the response type methods does not have to be a `Map<String, ?>` or `Jsonable`; it can also
170+
be a `String`, `byte[]` (for binary types) or `Collection` (for JSON arrays). The following will work, for example:
171+
172+
```java
173+
String response = client.getCustomClient().get("https://example.com");
174+
```
175+
176+
#### Advanced Usage
177+
The `CustomClient` provides preset methods for the supported HTTP request types and JSON-based request bodies.
178+
However, if you would like to make a request with non-JSON body (e.g. binary data), you can use the `makeRequest` method.
179+
This is a more convenient way of using `com.vonage.client.DynamicEndpoint` which takes care of most of the setup for you.
180+
181+
#### Caveats
182+
Whilst the `CustomClient` class is a powerful tool, it is not intended to be a replacement for dedicated support
183+
which the SDK provides for Vonage APIs. Furthermore, you may notice your IDE giving warnings like
184+
"Unchecked generics array creation for varargs parameter". This is because all methods in `CustomClient` use a
185+
varargs parameter for the response type as a way to infer the response type without you having to explicitly provide
186+
the `Class<R>` parameter. This is a known limitation of Java generics and is not a problem with the SDK itself, it is
187+
implemented this way for your convenience. As per the documentation, it is important to not pass any value for this
188+
varargs parameter — just omit it. If you do pass a value, the SDK will not be able to infer the response type.
189+
You should also always use explicit assignment for the `CustomClient` methods, as the SDK will not be able to infer the return type if you use `var` or `Object`.
190+
If you do not assign the response to a typed variable explicitly, `Void` will be inferred and the method will return `null`.
191+
115192
## Configuration
116193

117194
## Typical Instantiation

pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<groupId>com.vonage</groupId>
77
<artifactId>server-sdk</artifactId>
8-
<version>9.0.0</version>
8+
<version>9.1.0</version>
99

1010
<name>Vonage Java Server SDK</name>
1111
<description>Java client for Vonage APIs</description>
@@ -87,7 +87,7 @@
8787
<dependency>
8888
<groupId>org.junit.jupiter</groupId>
8989
<artifactId>junit-jupiter-engine</artifactId>
90-
<version>5.12.1</version>
90+
<version>5.12.2</version>
9191
<scope>test</scope>
9292
</dependency>
9393
<dependency>
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Copyright 2025 Vonage
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.vonage.client;
17+
18+
import com.fasterxml.jackson.annotation.JsonAnyGetter;
19+
import com.fasterxml.jackson.annotation.JsonAnySetter;
20+
import com.vonage.client.auth.AuthMethod;
21+
import com.vonage.client.common.HttpMethod;
22+
import org.apache.http.HttpResponse;
23+
import java.util.Collection;
24+
import java.util.Map;
25+
26+
/**
27+
* Client for making custom requests to Vonage APIs unsupported by this SDK.
28+
* This is useful for testing out beta APIs or making custom requests where the SDK falls short.
29+
* You can specify the HTTP method, endpoint URL, request body parameters and response object to parse.
30+
* The implementation is based on {@link DynamicEndpoint}.
31+
* <p>
32+
* The supported request and response types (i.e. the {@code <T>} and {@code <R>} generics)
33+
* should be instances of {@link Jsonable}. See the {@linkplain DynamicEndpoint#parseResponse(HttpResponse)}
34+
* method for how deserialisation is handled.
35+
* <p>
36+
* The valid types for the return type parameter {@code <R>} are generally:
37+
* <ul>
38+
* <li>{@link Jsonable} - for parsing the response body into a JSON object</li>
39+
* <li>{@link Map} - for parsing the response body as JSON into a tree structure</li>
40+
* <li>{@link Collection} - for parsing the response body as JSON into a list of objects</li>
41+
* <li>{@link Void} - for ignoring the response body</li>
42+
* <li>{@code byte[]} - for parsing the response body as binary</li>
43+
* <li>{@link String} - for returning the response body directly as a string</li>
44+
* </ul>
45+
*
46+
* @since 9.1.0
47+
*/
48+
@SuppressWarnings("unchecked")
49+
public class CustomClient {
50+
private final HttpWrapper httpWrapper;
51+
52+
/**
53+
* Wrapper for converting Map to JSON and vice versa.
54+
*/
55+
static final class JsonableMap extends JsonableBaseObject {
56+
@JsonAnyGetter @JsonAnySetter Map<String, Object> body;
57+
58+
JsonableMap(Map<String, ?> body) {
59+
this.body = (Map<String, Object>) body;
60+
}
61+
}
62+
63+
/**
64+
* Constructor for creating a custom client.
65+
*
66+
* @param httpWrapper Shared HTTP wrapper object and configuration used for making REST calls.
67+
*/
68+
public CustomClient(HttpWrapper httpWrapper) {
69+
this.httpWrapper = httpWrapper;
70+
}
71+
72+
/**
73+
* Most flexible method for making custom requests. This advanced option should only be used
74+
* if you are familiar with the underlying {@linkplain DynamicEndpoint} implementation.
75+
*
76+
* @param requestMethod The HTTP method to use for the request.
77+
* @param url Absolute URL to send the request to as a string.
78+
* @param requestBody The payload to send in the request body.
79+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
80+
*
81+
* @return The parsed response object, or {@code null} if absent / not applicable.
82+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
83+
*
84+
* @param <T> The request body type.
85+
* @param <R> The response body type.
86+
*/
87+
public <T, R> R makeRequest(HttpMethod requestMethod, String url, T requestBody, R... responseType) {
88+
return DynamicEndpoint.<T, R> builder(fixResponseType(responseType))
89+
.wrapper(httpWrapper).requestMethod(requestMethod)
90+
.authMethod(AuthMethod.class) // All available methods are acceptable
91+
.pathGetter((de, req) -> url)
92+
.build().execute(requestBody);
93+
}
94+
95+
private <R> R[] fixResponseType(R... responseType) {
96+
return responseType == null || Object.class.equals(responseType.getClass().getComponentType()) ?
97+
(R[]) new Void[0] : responseType;
98+
}
99+
100+
/**
101+
* Convenience method for making DELETE requests.
102+
* In most cases, you should assign the return value to Void.
103+
*
104+
* @param url URL to send the request to as a string.
105+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
106+
*
107+
* @return The parsed response object, or {@code null} if absent / not applicable.
108+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
109+
*
110+
* @param <R> The response body type, most likely {@linkplain Void}.
111+
*/
112+
public <R> R delete(String url, R... responseType) {
113+
return makeRequest(HttpMethod.DELETE, url, null, responseType);
114+
}
115+
116+
/**
117+
* Convenience method for making GET requests.
118+
*
119+
* @param url URL to send the request to as a string.
120+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
121+
*
122+
* @return The parsed response object.
123+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
124+
*
125+
* @param <R> The response body type.
126+
*/
127+
public <R> R get(String url, R... responseType) {
128+
return makeRequest(HttpMethod.GET, url, null, responseType);
129+
}
130+
131+
/**
132+
* Convenience method for making POST requests.
133+
*
134+
* @param url URL to send the request to as a string.
135+
* @param requestBody The payload to send in the request body.
136+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
137+
*
138+
* @return The parsed response object, or {@code null} if absent / not applicable.
139+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
140+
*
141+
* @param <R> The response body type.
142+
*/
143+
public <R> R post(String url, Jsonable requestBody, R... responseType) {
144+
return makeRequest(HttpMethod.POST, url, requestBody, responseType);
145+
}
146+
147+
/**
148+
* Convenience method for making JSON-based POST requests.
149+
*
150+
* @param url URL to send the request to as a string.
151+
* @param requestBody The payload to convert to JSON and send in the request body.
152+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
153+
*
154+
* @return The parsed response object, or {@code null} if absent / not applicable.
155+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
156+
*
157+
* @param <R> The response body type.
158+
*/
159+
public <R> R post(String url, Map<String, ?> requestBody, R... responseType) {
160+
return post(url, new JsonableMap(requestBody), responseType);
161+
}
162+
163+
/**
164+
* Convenience method for making PUT requests.
165+
*
166+
* @param url URL to send the request to as a string.
167+
* @param requestBody The payload to send in the request body.
168+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
169+
*
170+
* @return The parsed response object, or {@code null} if absent / not applicable.
171+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
172+
*
173+
* @param <R> The response body type.
174+
*/
175+
public <R> R put(String url, Jsonable requestBody, R... responseType) {
176+
return makeRequest(HttpMethod.PUT, url, requestBody, responseType);
177+
}
178+
179+
/**
180+
* Convenience method for making JSON-based PUT requests.
181+
*
182+
* @param url URL to send the request to as a string.
183+
* @param requestBody The payload to convert to JSON and send in the request body.
184+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
185+
*
186+
* @return The parsed response object, or {@code null} if absent / not applicable.
187+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
188+
*
189+
* @param <R> The response body type.
190+
*/
191+
public <R> R put(String url, Map<String, ?> requestBody, R... responseType) {
192+
return put(url, new JsonableMap(requestBody), responseType);
193+
}
194+
195+
/**
196+
* Convenience method for making PATCH requests.
197+
*
198+
* @param url URL to send the request to as a string.
199+
* @param requestBody The payload to send in the request body.
200+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
201+
*
202+
* @return The parsed response object, or {@code null} if absent / not applicable.
203+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
204+
*
205+
* @param <R> The response body type.
206+
*/
207+
public <R> R patch(String url, Jsonable requestBody, R... responseType) {
208+
return makeRequest(HttpMethod.PATCH, url, requestBody, responseType);
209+
}
210+
211+
/**
212+
* Convenience method for making JSON-based PATCH requests.
213+
*
214+
* @param url URL to send the request to as a string.
215+
* @param requestBody The payload to convert to JSON and send in the request body.
216+
* @param responseType Hack for type inference. Do not provide this field (especially, DO NOT pass {@code null}).
217+
*
218+
* @return The parsed response object, or {@code null} if absent / not applicable.
219+
* @throws VonageApiResponseException If the HTTP response code is >= 400.
220+
*
221+
* @param <R> The response body type.
222+
*/
223+
public <R> R patch(String url, Map<String, ?> requestBody, R... responseType) {
224+
return patch(url, new JsonableMap(requestBody), responseType);
225+
}
226+
}

src/main/java/com/vonage/client/DynamicEndpoint.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ protected DynamicEndpoint(Builder<T, R> builder) {
5858
authMethods = Objects.requireNonNull(builder.authMethods, "At least one auth method must be defined.");
5959
requestMethod = Objects.requireNonNull(builder.requestMethod, "HTTP request method is required.");
6060
pathGetter = Objects.requireNonNull(builder.pathGetter, "Path function is required.");
61-
responseType = Objects.requireNonNull(builder.responseType, "Response type is required.");
61+
if ((responseType = builder.responseType) == Object.class) {
62+
throw new IllegalStateException(
63+
"Could not infer the response type." +
64+
"Please provide it explicitly, or do not use var when assigning the result."
65+
);
66+
}
6267
responseExceptionType = builder.responseExceptionType;
6368
contentType = builder.contentType;
6469
accept = builder.accept == null &&
@@ -96,7 +101,7 @@ public static final class Builder<T, R> {
96101
private Class<? extends VonageApiResponseException> responseExceptionType;
97102

98103
Builder(Class<R> responseType) {
99-
this.responseType = responseType;
104+
this.responseType = Objects.requireNonNull(responseType, "Response type class cannot be null.");
100105
}
101106

102107
public Builder<T, R> wrapper(HttpWrapper wrapper) {
@@ -309,7 +314,11 @@ else if (byte[].class.equals(responseType)) {
309314
if (Jsonable.class.isAssignableFrom(responseType)) {
310315
return (R) Jsonable.fromJson(deser, (Class<? extends Jsonable>) responseType);
311316
}
312-
else if (Collection.class.isAssignableFrom(responseType) || isJsonableArrayResponse()) {
317+
else if (
318+
Map.class.isAssignableFrom(responseType) ||
319+
Collection.class.isAssignableFrom(responseType) ||
320+
isJsonableArrayResponse()
321+
) {
313322
return Jsonable.createDefaultObjectMapper().readValue(deser, responseType);
314323
}
315324
else {

0 commit comments

Comments
 (0)