001/* 002 * Copyright 2024 Vonage 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package com.vonage.client; 017 018import com.vonage.client.auth.AuthMethod; 019import com.vonage.client.common.HttpMethod; 020import org.apache.http.HttpResponse; 021import org.apache.http.client.methods.RequestBuilder; 022import org.apache.http.entity.ByteArrayEntity; 023import org.apache.http.entity.ContentType; 024import org.apache.http.entity.StringEntity; 025import org.apache.http.util.EntityUtils; 026import java.io.IOException; 027import java.lang.reflect.Constructor; 028import java.lang.reflect.InvocationTargetException; 029import java.net.URI; 030import java.util.*; 031import java.util.function.BiFunction; 032import java.util.function.Consumer; 033 034/** 035 * Enables convenient declaration of endpoints without directly implementing {@link AbstractMethod}. 036 * This decouples the endpoint's implementation from the underlying HTTP library. 037 * 038 * @param <T> The request body type. 039 * @param <R> The response body type. 040 * 041 * @since 7.7.0 042 */ 043@SuppressWarnings("unchecked") 044public class DynamicEndpoint<T, R> extends AbstractMethod<T, R> { 045 protected Set<Class<? extends AuthMethod>> authMethods; 046 protected String contentType, accept; 047 protected HttpMethod requestMethod; 048 protected BiFunction<DynamicEndpoint<T, R>, ? super T, String> pathGetter; 049 protected Class<? extends RuntimeException> responseExceptionType; 050 protected Class<R> responseType; 051 protected T cachedRequestBody; 052 053 protected DynamicEndpoint(Builder<T, R> builder) { 054 super(builder.wrapper); 055 authMethods = Objects.requireNonNull(builder.authMethods, "At least one auth method must be defined."); 056 requestMethod = Objects.requireNonNull(builder.requestMethod, "HTTP request method is required."); 057 pathGetter = Objects.requireNonNull(builder.pathGetter, "Path function is required."); 058 responseType = Objects.requireNonNull(builder.responseType, "Response type is required."); 059 responseExceptionType = builder.responseExceptionType; 060 contentType = builder.contentType; 061 if ((accept = builder.accept) == null && 062 (Jsonable.class.isAssignableFrom(responseType) || isJsonableArrayResponse()) 063 ) { 064 accept = ContentType.APPLICATION_JSON.getMimeType(); 065 } 066 } 067 068 /** 069 * This trick enables initialisation of the builder whilst inferring the response type {@code <R>} 070 * without directly providing the class by using varargs as a parameter. See usages for examples. 071 * 072 * @param responseType The response type array, not provided directly but via a varargs parameter. 073 * 074 * @return A new Builder. 075 * 076 * @param <T> The request type. 077 * @param <R> The response type. 078 * @since 7.9.0 079 */ 080 public static <T, R> Builder<T, R> builder(R[] responseType) { 081 return builder((Class<R>) responseType.getClass().getComponentType()); 082 } 083 084 public static <T, R> Builder<T, R> builder(Class<R> responseType) { 085 return new Builder<>(responseType); 086 } 087 088 public static final class Builder<T, R> { 089 private final Class<R> responseType; 090 private Set<Class<? extends AuthMethod>> authMethods; 091 private HttpWrapper wrapper; 092 private String contentType, accept; 093 private HttpMethod requestMethod; 094 private BiFunction<DynamicEndpoint<T, R>, ? super T, String> pathGetter; 095 private Class<? extends RuntimeException> responseExceptionType; 096 097 Builder(Class<R> responseType) { 098 this.responseType = responseType; 099 } 100 101 public Builder<T, R> wrapper(HttpWrapper wrapper) { 102 this.wrapper = wrapper; 103 return this; 104 } 105 106 public Builder<T, R> requestMethod(HttpMethod requestMethod) { 107 this.requestMethod = requestMethod; 108 return this; 109 } 110 111 public Builder<T, R> pathGetter(BiFunction<DynamicEndpoint<T, R>, T, String> pathGetter) { 112 this.pathGetter = pathGetter; 113 return this; 114 } 115 116 public Builder<T, R> authMethod(Class<? extends AuthMethod> primary, Class<? extends AuthMethod>... others) { 117 authMethods = new LinkedHashSet<>(2); 118 authMethods.add(Objects.requireNonNull(primary, "Primary auth method cannot be null.")); 119 if (others != null) { 120 for (Class<? extends AuthMethod> amc : others) { 121 if (amc != null) { 122 authMethods.add(amc); 123 } 124 } 125 } 126 return this; 127 } 128 129 public Builder<T, R> responseExceptionType(Class<? extends RuntimeException> responseExceptionType) { 130 this.responseExceptionType = responseExceptionType; 131 return this; 132 } 133 134 public Builder<T, R> urlFormEncodedContentType(boolean formEncoded) { 135 return contentTypeHeader(formEncoded ? "application/x-www-form-urlencoded" : null); 136 } 137 138 public Builder<T, R> contentTypeHeader(String contentType) { 139 this.contentType = contentType; 140 return this; 141 } 142 143 public Builder<T, R> acceptHeader(String accept) { 144 this.accept = accept; 145 return this; 146 } 147 148 public DynamicEndpoint<T, R> build() { 149 return new DynamicEndpoint<>(this); 150 } 151 } 152 153 static RequestBuilder createRequestBuilderFromRequestMethod(HttpMethod requestMethod) { 154 switch (requestMethod) { 155 case GET: return RequestBuilder.get(); 156 case POST: return RequestBuilder.post(); 157 case PATCH: return RequestBuilder.patch(); 158 case DELETE: return RequestBuilder.delete(); 159 case PUT: return RequestBuilder.put(); 160 default: throw new IllegalStateException("Unknown request method."); 161 } 162 } 163 164 @Override 165 protected final Set<Class<? extends AuthMethod>> getAcceptableAuthMethods() { 166 return authMethods; 167 } 168 169 private boolean isJsonableArrayResponse() { 170 return responseType.isArray() && Jsonable.class.isAssignableFrom(responseType.getComponentType()); 171 } 172 173 private String getRequestHeader(T requestBody) { 174 if (contentType != null) { 175 return contentType; 176 } 177 else if (requestBody instanceof Jsonable) { 178 return ContentType.APPLICATION_JSON.getMimeType(); 179 } 180 else if (requestBody instanceof BinaryRequest) { 181 return ((BinaryRequest) requestBody).getContentType(); 182 } 183 else { 184 return null; 185 } 186 } 187 188 private static void applyQueryParams(Map<String, ?> params, RequestBuilder rqb) { 189 params.forEach((k, v) -> { 190 Consumer<Object> logic = obj -> rqb.addParameter(k, String.valueOf(obj)); 191 if (v instanceof Object[]) { 192 for (Object nested : (Object[]) v) { 193 logic.accept(nested); 194 } 195 } 196 else if (v instanceof Iterable<?>) { 197 for (Object nested : (Iterable<?>) v) { 198 logic.accept(nested); 199 } 200 } 201 else { 202 logic.accept(v); 203 } 204 }); 205 } 206 207 public static URI buildUri(String base, Map<String, ?> requestParams) { 208 RequestBuilder requestBuilder = RequestBuilder.get(base); 209 applyQueryParams(requestParams, requestBuilder); 210 return requestBuilder.build().getURI(); 211 } 212 213 @Override 214 protected final RequestBuilder makeRequest(T requestBody) { 215 if (requestBody instanceof Jsonable && responseType.isAssignableFrom(requestBody.getClass())) { 216 cachedRequestBody = requestBody; 217 } 218 RequestBuilder rqb = createRequestBuilderFromRequestMethod(requestMethod); 219 String header = getRequestHeader(requestBody); 220 if (header != null) { 221 rqb.setHeader("Content-Type", header); 222 } 223 if (accept != null) { 224 rqb.setHeader("Accept", accept); 225 } 226 if (requestBody instanceof QueryParamsRequest) { 227 applyQueryParams(((QueryParamsRequest) requestBody).makeParams(), rqb); 228 } 229 if (requestBody instanceof Jsonable) { 230 rqb.setEntity(new StringEntity(((Jsonable) requestBody).toJson(), ContentType.APPLICATION_JSON)); 231 } 232 else if (requestBody instanceof BinaryRequest) { 233 BinaryRequest bin = (BinaryRequest) requestBody; 234 rqb.setEntity(new ByteArrayEntity(bin.toByteArray(), ContentType.getByMimeType(bin.getContentType()))); 235 } 236 else if (requestBody instanceof byte[]) { 237 rqb.setEntity(new ByteArrayEntity((byte[]) requestBody)); 238 } 239 return rqb.setUri(pathGetter.apply(this, requestBody)); 240 } 241 242 @Override 243 protected final R parseResponse(HttpResponse response) throws IOException { 244 int statusCode = response.getStatusLine().getStatusCode(); 245 try { 246 if (statusCode >= 200 && statusCode < 300) { 247 return parseResponseSuccess(response); 248 } 249 else if (statusCode >= 300 && statusCode < 400) { 250 return parseResponseRedirect(response); 251 } 252 else { 253 return parseResponseFailure(response); 254 } 255 } 256 catch (InvocationTargetException ex) { 257 Throwable wrapped = ex.getTargetException(); 258 if (wrapped instanceof RuntimeException) { 259 throw (RuntimeException) wrapped; 260 } 261 else { 262 throw new VonageUnexpectedException(wrapped); 263 } 264 } 265 catch (ReflectiveOperationException ex) { 266 throw new VonageUnexpectedException(ex); 267 } 268 finally { 269 cachedRequestBody = null; 270 } 271 } 272 273 protected R parseResponseFromString(String response) { 274 return null; 275 } 276 277 private R parseResponseRedirect(HttpResponse response) throws ReflectiveOperationException, IOException { 278 final String location = response.getFirstHeader("Location").getValue(); 279 280 if (java.net.URI.class.equals(responseType)) { 281 return (R) URI.create(location); 282 } 283 else if (String.class.equals(responseType)) { 284 return (R) location; 285 } 286 else { 287 return parseResponseSuccess(response); 288 } 289 } 290 291 private R parseResponseSuccess(HttpResponse response) throws IOException, ReflectiveOperationException { 292 if (Void.class.equals(responseType)) { 293 return null; 294 } 295 else if (byte[].class.equals(responseType)) { 296 return (R) EntityUtils.toByteArray(response.getEntity()); 297 } 298 else { 299 String deser = EntityUtils.toString(response.getEntity()); 300 301 if (responseType.equals(String.class)) { 302 return (R) deser; 303 } 304 305 if (cachedRequestBody instanceof Jsonable) { 306 ((Jsonable) cachedRequestBody).updateFromJson(deser); 307 return (R) cachedRequestBody; 308 } 309 310 if (Jsonable.class.isAssignableFrom(responseType)) { 311 return (R) Jsonable.fromJson(deser, (Class<? extends Jsonable>) responseType); 312 } 313 else if (Collection.class.isAssignableFrom(responseType) || isJsonableArrayResponse()) { 314 return Jsonable.createDefaultObjectMapper().readValue(deser, responseType); 315 } 316 else { 317 R customParsedResponse = parseResponseFromString(deser); 318 if (customParsedResponse == null) { 319 throw new IllegalStateException("Unhandled return type: " + responseType); 320 } 321 else { 322 return customParsedResponse; 323 } 324 } 325 } 326 } 327 328 private R parseResponseFailure(HttpResponse response) throws IOException, ReflectiveOperationException { 329 String exMessage = EntityUtils.toString(response.getEntity()); 330 if (responseExceptionType != null) { 331 if (VonageApiResponseException.class.isAssignableFrom(responseExceptionType)) { 332 VonageApiResponseException varex = Jsonable.fromJson(exMessage, 333 (Class<? extends VonageApiResponseException>) responseExceptionType 334 ); 335 if (varex.title == null) { 336 varex.title = response.getStatusLine().getReasonPhrase(); 337 } 338 varex.statusCode = response.getStatusLine().getStatusCode(); 339 throw varex; 340 } 341 else { 342 for (Constructor<?> constructor : responseExceptionType.getDeclaredConstructors()) { 343 Class<?>[] params = constructor.getParameterTypes(); 344 if (params.length == 1 && String.class.equals(params[0])) { 345 if (!constructor.isAccessible()) { 346 constructor.setAccessible(true); 347 } 348 throw (RuntimeException) constructor.newInstance(exMessage); 349 } 350 } 351 } 352 } 353 R customParsedResponse = parseResponseFromString(exMessage); 354 if (customParsedResponse == null) { 355 throw new VonageApiResponseException(exMessage); 356 } 357 else { 358 return customParsedResponse; 359 } 360 } 361}