001package com.box.sdkgen.networking.boxnetworkclient; 002 003import static com.box.sdkgen.box.BoxConstants.USER_AGENT_HEADER; 004import static com.box.sdkgen.box.BoxConstants.X_BOX_UA_HEADER; 005import static com.box.sdkgen.internal.utils.UtilsManager.readByteStream; 006import static com.box.sdkgen.serialization.json.JsonManager.jsonToSerializedData; 007import static com.box.sdkgen.serialization.json.JsonManager.sdToJson; 008import static com.box.sdkgen.serialization.json.JsonManager.sdToUrlParams; 009import static java.util.Collections.singletonList; 010import static okhttp3.ConnectionSpec.MODERN_TLS; 011 012import com.box.sdkgen.box.errors.BoxAPIError; 013import com.box.sdkgen.box.errors.BoxSDKError; 014import com.box.sdkgen.internal.logging.DataSanitizer; 015import com.box.sdkgen.networking.fetchoptions.FetchOptions; 016import com.box.sdkgen.networking.fetchoptions.MultipartItem; 017import com.box.sdkgen.networking.fetchoptions.ResponseFormat; 018import com.box.sdkgen.networking.fetchresponse.FetchResponse; 019import com.box.sdkgen.networking.network.NetworkSession; 020import com.box.sdkgen.networking.networkclient.NetworkClient; 021import com.box.sdkgen.networking.proxyconfig.ProxyConfig; 022import com.box.sdkgen.networking.timeoutconfig.TimeoutConfig; 023import com.fasterxml.jackson.databind.JsonNode; 024import java.io.IOException; 025import java.net.InetSocketAddress; 026import java.net.Proxy; 027import java.net.URI; 028import java.nio.charset.StandardCharsets; 029import java.util.Locale; 030import java.util.Map; 031import java.util.Objects; 032import java.util.Optional; 033import java.util.TreeMap; 034import java.util.concurrent.TimeUnit; 035import java.util.stream.Collectors; 036import okhttp3.Call; 037import okhttp3.Credentials; 038import okhttp3.Headers; 039import okhttp3.HttpUrl; 040import okhttp3.MediaType; 041import okhttp3.MultipartBody; 042import okhttp3.OkHttpClient; 043import okhttp3.Request; 044import okhttp3.RequestBody; 045import okhttp3.Response; 046import okio.BufferedSink; 047import okio.Okio; 048import okio.Source; 049 050public class BoxNetworkClient implements NetworkClient { 051 052 private static final int BASE_TIMEOUT = 1; 053 private static final double RANDOM_FACTOR = 0.5; 054 private static final int DEFAULT_HTTP_PORT = 80; 055 private static final int DEFAULT_HTTPS_PORT = 443; 056 057 protected OkHttpClient httpClient; 058 059 public BoxNetworkClient(OkHttpClient httpClient) { 060 this.httpClient = httpClient; 061 } 062 063 public BoxNetworkClient() { 064 httpClient = getDefaultOkHttpClientBuilder().build(); 065 } 066 067 public OkHttpClient getHttpClient() { 068 return httpClient; 069 } 070 071 public BoxNetworkClient withProxy(ProxyConfig config) { 072 URI uri = URI.create(config.getUrl()); 073 String host = Objects.requireNonNull(uri.getHost(), "Invalid Proxy URL"); 074 075 String scheme = 076 Optional.ofNullable(uri.getScheme()) 077 .filter(schema -> schema.startsWith("http")) 078 .orElseThrow(() -> new IllegalArgumentException("Invalid Proxy URL: " + uri)); 079 080 int port = 081 (uri.getPort() != -1) 082 ? uri.getPort() 083 : ("https".equalsIgnoreCase(scheme) ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT); 084 085 OkHttpClient.Builder clientBuilder = 086 httpClient 087 .newBuilder() 088 .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port))); 089 090 String username = config.getUsername(); 091 String password = config.getPassword(); 092 if (username != null && !username.trim().isEmpty() && password != null) { 093 String basic = Credentials.basic(username, password, StandardCharsets.UTF_8); 094 clientBuilder.proxyAuthenticator( 095 (route, resp) -> 096 resp.request().newBuilder().header("Proxy-Authorization", basic).build()); 097 } 098 return new BoxNetworkClient(clientBuilder.build()); 099 } 100 101 public BoxNetworkClient withTimeoutConfig(TimeoutConfig config) { 102 if (config == null) { 103 throw new IllegalArgumentException("TimeoutConfig cannot be null"); 104 } 105 106 OkHttpClient.Builder clientBuilder = httpClient.newBuilder(); 107 108 Long connectionTimeoutMs = config.getConnectionTimeoutMs(); 109 if (connectionTimeoutMs != null) { 110 if (connectionTimeoutMs < 0) { 111 throw new IllegalArgumentException("connectionTimeoutMs cannot be negative"); 112 } 113 clientBuilder.connectTimeout(connectionTimeoutMs.longValue(), TimeUnit.MILLISECONDS); 114 } 115 116 Long readTimeoutMs = config.getReadTimeoutMs(); 117 if (readTimeoutMs != null) { 118 if (readTimeoutMs < 0) { 119 throw new IllegalArgumentException("readTimeoutMs cannot be negative"); 120 } 121 clientBuilder.readTimeout(readTimeoutMs.longValue(), TimeUnit.MILLISECONDS); 122 } 123 124 Long requestTimeoutMs = config.getRequestTimeoutMs(); 125 if (requestTimeoutMs != null) { 126 if (requestTimeoutMs < 0) { 127 throw new IllegalArgumentException("requestTimeoutMs cannot be negative"); 128 } 129 clientBuilder.callTimeout(requestTimeoutMs.longValue(), TimeUnit.MILLISECONDS); 130 } 131 return new BoxNetworkClient(clientBuilder.build()); 132 } 133 134 public FetchResponse fetch(FetchOptions options) { 135 NetworkSession networkSession = 136 options.getNetworkSession() == null ? new NetworkSession() : options.getNetworkSession(); 137 138 FetchOptions fetchOptions = 139 networkSession.getInterceptors().stream() 140 .reduce( 141 options, 142 (modifiedOptions, interceptor) -> interceptor.beforeRequest(modifiedOptions), 143 (o1, o2) -> o2); 144 145 boolean authenticationNeeded = false; 146 Request request; 147 FetchResponse fetchResponse = new FetchResponse.Builder(0, new TreeMap<>()).build(); 148 Exception exceptionThrown = null; 149 150 int attemptNumber = 1; 151 int numberOfRetriesOnException = 0; 152 int attemptForRetry = 0; 153 boolean shouldRetry = false; 154 155 while (true) { 156 request = prepareRequest(fetchOptions, authenticationNeeded, networkSession); 157 158 Response response = null; 159 String rawResponseBody = null; 160 161 try { 162 response = executeOnClient(request); 163 164 Map<String, String> headersMap = 165 response.headers().toMultimap().entrySet().stream() 166 .collect( 167 Collectors.toMap( 168 Map.Entry::getKey, 169 e -> e.getValue().get(0), 170 (existing, replacement) -> existing, 171 () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER))); 172 173 String responseUrl = 174 response.networkResponse() != null 175 ? response.networkResponse().request().url().toString() 176 : response.request().url().toString(); 177 178 attemptForRetry = attemptNumber; 179 180 if (Objects.equals( 181 fetchOptions.getResponseFormat().getEnumValue(), ResponseFormat.BINARY)) { 182 fetchResponse = 183 new FetchResponse.Builder(response.code(), headersMap) 184 .content(response.body().byteStream()) 185 .url(responseUrl) 186 .build(); 187 } else { 188 rawResponseBody = response.body() != null ? response.body().string() : null; 189 fetchResponse = 190 new FetchResponse.Builder(response.code(), headersMap) 191 .data(readJsonFromRawBody(rawResponseBody)) 192 .url(responseUrl) 193 .build(); 194 } 195 196 fetchResponse = 197 networkSession.getInterceptors().stream() 198 .reduce( 199 fetchResponse, 200 (modifiedResponse, interceptor) -> interceptor.afterRequest(modifiedResponse), 201 (o1, o2) -> o2); 202 203 } catch (Exception e) { 204 exceptionThrown = e; 205 numberOfRetriesOnException++; 206 attemptForRetry = numberOfRetriesOnException; 207 fetchResponse = new FetchResponse.Builder(0, new TreeMap<>()).build(); 208 rawResponseBody = null; 209 if (response != null) { 210 response.close(); 211 } 212 } 213 214 shouldRetry = 215 networkSession 216 .getRetryStrategy() 217 .shouldRetry(fetchOptions, fetchResponse, attemptForRetry); 218 219 if (shouldRetry) { 220 double retryDelay = 221 networkSession 222 .getRetryStrategy() 223 .retryAfter(fetchOptions, fetchResponse, attemptForRetry); 224 if (retryDelay > 0) { 225 try { 226 TimeUnit.SECONDS.sleep((long) retryDelay); 227 } catch (InterruptedException ie) { 228 Thread.currentThread().interrupt(); 229 throw new BoxSDKError("Retry interrupted", ie); 230 } 231 } 232 attemptNumber++; 233 continue; 234 } 235 236 if (fetchResponse.getStatus() >= 300 237 && fetchResponse.getStatus() < 400 238 && fetchOptions.followRedirects) { 239 if (!fetchResponse.getHeaders().containsKey("Location")) { 240 throw new BoxSDKError( 241 "Redirect response missing Location header for " + fetchOptions.getUrl()); 242 } 243 URI originalUri = URI.create(fetchOptions.getUrl()); 244 URI redirectUri = URI.create(fetchResponse.getHeaders().get("Location")); 245 boolean sameOrigin = 246 originalUri.getHost().equals(redirectUri.getHost()) 247 && originalUri.getPort() == redirectUri.getPort() 248 && originalUri.getScheme().equals(redirectUri.getScheme()); 249 return fetch( 250 new FetchOptions.Builder(fetchResponse.getHeaders().get("Location"), "GET") 251 .responseFormat(fetchOptions.getResponseFormat()) 252 .auth(sameOrigin ? fetchOptions.getAuth() : null) 253 .networkSession(networkSession) 254 .build()); 255 } 256 257 if (fetchResponse.getStatus() >= 200 && fetchResponse.getStatus() < 400) { 258 return fetchResponse; 259 } 260 261 throwOnUnsuccessfulResponse( 262 request, 263 fetchResponse, 264 rawResponseBody, 265 exceptionThrown, 266 fetchOptions.getContentType(), 267 networkSession.getDataSanitizer()); 268 } 269 } 270 271 private static Request prepareRequest( 272 FetchOptions options, boolean reauthenticate, NetworkSession networkSession) { 273 Request.Builder requestBuilder = new Request.Builder().url(options.getUrl()); 274 Headers headers = prepareHeaders(options, reauthenticate, networkSession); 275 HttpUrl url = prepareUrl(options); 276 RequestBody body = prepareRequestBody(options); 277 278 requestBuilder.headers(headers); 279 requestBuilder.url(url); 280 requestBuilder.method(options.getMethod().toUpperCase(Locale.ROOT), body); 281 return requestBuilder.build(); 282 } 283 284 private static Headers prepareHeaders( 285 FetchOptions options, boolean reauthenticate, NetworkSession networkSession) { 286 Headers.Builder headersBuilder = new Headers.Builder(); 287 288 networkSession.getAdditionalHeaders().forEach(headersBuilder::add); 289 290 if (options.getHeaders() != null) { 291 options.getHeaders().forEach(headersBuilder::add); 292 } 293 if (options.getAuth() != null) { 294 if (reauthenticate) { 295 options.getAuth().refreshToken(networkSession); 296 } 297 headersBuilder.add( 298 "Authorization", options.getAuth().retrieveAuthorizationHeader(networkSession)); 299 } 300 headersBuilder.add("User-Agent", USER_AGENT_HEADER); 301 headersBuilder.add("X-Box-UA", X_BOX_UA_HEADER); 302 return headersBuilder.build(); 303 } 304 305 private static HttpUrl prepareUrl(FetchOptions options) { 306 307 HttpUrl baseUrl = HttpUrl.parse(options.getUrl()); 308 if (baseUrl == null) { 309 throw new IllegalArgumentException("Invalid URL " + options.getUrl()); 310 } 311 HttpUrl.Builder urlBuilder = baseUrl.newBuilder(); 312 if (options.getParams() != null) { 313 options.getParams().forEach(urlBuilder::addQueryParameter); 314 } 315 return urlBuilder.build(); 316 } 317 318 private static RequestBody prepareRequestBody(FetchOptions options) { 319 if (options.getMethod().equalsIgnoreCase("GET")) { 320 return null; 321 } 322 String contentType = options.getContentType(); 323 MediaType mediaType = MediaType.parse(contentType); 324 switch (contentType) { 325 case "application/json": 326 case "application/json-patch+json": 327 return options.getData() != null 328 ? RequestBody.create(sdToJson(options.getData()), mediaType) 329 : RequestBody.create("", mediaType); 330 case "application/x-www-form-urlencoded": 331 return options.getData() != null 332 ? RequestBody.create(sdToUrlParams(options.getData()), mediaType) 333 : RequestBody.create("", mediaType); 334 case "multipart/form-data": 335 MultipartBody.Builder bodyBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM); 336 for (MultipartItem part : options.multipartData) { 337 if (part.getData() != null) { 338 bodyBuilder.addFormDataPart(part.getPartName(), sdToJson(part.getData())); 339 } else { 340 bodyBuilder.addFormDataPart( 341 part.getPartName(), 342 part.getFileName() != null ? part.getFileName() : "file", 343 createMultipartRequestBody(part)); 344 } 345 } 346 return bodyBuilder.build(); 347 case "application/octet-stream": 348 return RequestBody.create(readByteStream(options.getFileStream()), mediaType); 349 default: 350 throw new IllegalArgumentException("Unsupported content type " + contentType); 351 } 352 } 353 354 protected Call createNewCall(Request request) { 355 return this.httpClient.newCall(request); 356 } 357 358 private Response executeOnClient(Request request) throws IOException { 359 return createNewCall(request).execute(); 360 } 361 362 private static JsonNode readJsonFromRawBody(String rawResponseBody) { 363 if (rawResponseBody == null) { 364 return null; 365 } 366 367 try { 368 return jsonToSerializedData(rawResponseBody); 369 } catch (Exception e) { 370 return null; 371 } 372 } 373 374 private static void throwOnUnsuccessfulResponse( 375 Request request, 376 FetchResponse fetchResponse, 377 String rawResponseBody, 378 Exception exceptionThrown, 379 String contentType, 380 DataSanitizer dataSanitizer) { 381 if (fetchResponse.getStatus() == 0 && exceptionThrown != null) { 382 throw new BoxSDKError(exceptionThrown.getMessage(), exceptionThrown); 383 } 384 try { 385 throw BoxAPIError.fromAPICall( 386 request, fetchResponse, rawResponseBody, dataSanitizer, contentType); 387 } finally { 388 try { 389 if (fetchResponse.getContent() != null) { 390 fetchResponse.getContent().close(); 391 } 392 } catch (IOException ignored) { 393 } 394 } 395 } 396 397 private static int getRetryAfterTimeInSeconds(int attemptNumber, String retryAfterHeader) { 398 399 if (retryAfterHeader != null) { 400 return Integer.parseInt(retryAfterHeader); 401 } 402 403 double minWindow = 1 - RANDOM_FACTOR; 404 double maxWindow = 1 + RANDOM_FACTOR; 405 double jitter = (Math.random() * (maxWindow - minWindow)) + minWindow; 406 return (int) (Math.pow(2, attemptNumber) * BASE_TIMEOUT * jitter); 407 } 408 409 public static RequestBody createMultipartRequestBody(MultipartItem part) { 410 return new RequestBody() { 411 @Override 412 public MediaType contentType() { 413 if (part.contentType != null) { 414 return MediaType.parse(part.contentType); 415 } 416 return MediaType.parse("application/octet-stream"); 417 } 418 419 @Override 420 public void writeTo(BufferedSink sink) throws IOException { 421 try (Source source = Okio.source(part.getFileStream())) { 422 sink.writeAll(source); 423 } 424 } 425 }; 426 } 427 428 public static OkHttpClient.Builder getDefaultOkHttpClientBuilder() { 429 return new OkHttpClient.Builder() 430 .followSslRedirects(true) 431 .followRedirects(false) 432 .connectionSpecs(singletonList(MODERN_TLS)) 433 .retryOnConnectionFailure(false); 434 } 435}