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 return new BoxNetworkClient(clientBuilder.build()); 124 } 125 126 public FetchResponse fetch(FetchOptions options) { 127 NetworkSession networkSession = 128 options.getNetworkSession() == null ? new NetworkSession() : options.getNetworkSession(); 129 130 FetchOptions fetchOptions = 131 networkSession.getInterceptors().stream() 132 .reduce( 133 options, 134 (modifiedOptions, interceptor) -> interceptor.beforeRequest(modifiedOptions), 135 (o1, o2) -> o2); 136 137 boolean authenticationNeeded = false; 138 Request request; 139 FetchResponse fetchResponse = new FetchResponse.Builder(0, new TreeMap<>()).build(); 140 Exception exceptionThrown = null; 141 142 int attemptNumber = 1; 143 int numberOfRetriesOnException = 0; 144 int attemptForRetry = 0; 145 boolean shouldRetry = false; 146 147 while (true) { 148 request = prepareRequest(fetchOptions, authenticationNeeded, networkSession); 149 150 Response response = null; 151 String rawResponseBody = null; 152 153 try { 154 response = executeOnClient(request); 155 156 Map<String, String> headersMap = 157 response.headers().toMultimap().entrySet().stream() 158 .collect( 159 Collectors.toMap( 160 Map.Entry::getKey, 161 e -> e.getValue().get(0), 162 (existing, replacement) -> existing, 163 () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER))); 164 165 String responseUrl = 166 response.networkResponse() != null 167 ? response.networkResponse().request().url().toString() 168 : response.request().url().toString(); 169 170 attemptForRetry = attemptNumber; 171 172 if (Objects.equals( 173 fetchOptions.getResponseFormat().getEnumValue(), ResponseFormat.BINARY)) { 174 fetchResponse = 175 new FetchResponse.Builder(response.code(), headersMap) 176 .content(response.body().byteStream()) 177 .url(responseUrl) 178 .build(); 179 } else { 180 rawResponseBody = response.body() != null ? response.body().string() : null; 181 fetchResponse = 182 new FetchResponse.Builder(response.code(), headersMap) 183 .data(readJsonFromRawBody(rawResponseBody)) 184 .url(responseUrl) 185 .build(); 186 } 187 188 fetchResponse = 189 networkSession.getInterceptors().stream() 190 .reduce( 191 fetchResponse, 192 (modifiedResponse, interceptor) -> interceptor.afterRequest(modifiedResponse), 193 (o1, o2) -> o2); 194 195 } catch (Exception e) { 196 exceptionThrown = e; 197 numberOfRetriesOnException++; 198 attemptForRetry = numberOfRetriesOnException; 199 fetchResponse = new FetchResponse.Builder(0, new TreeMap<>()).build(); 200 rawResponseBody = null; 201 if (response != null) { 202 response.close(); 203 } 204 } 205 206 shouldRetry = 207 networkSession 208 .getRetryStrategy() 209 .shouldRetry(fetchOptions, fetchResponse, attemptForRetry); 210 211 if (shouldRetry) { 212 double retryDelay = 213 networkSession 214 .getRetryStrategy() 215 .retryAfter(fetchOptions, fetchResponse, attemptForRetry); 216 if (retryDelay > 0) { 217 try { 218 TimeUnit.SECONDS.sleep((long) retryDelay); 219 } catch (InterruptedException ie) { 220 Thread.currentThread().interrupt(); 221 throw new BoxSDKError("Retry interrupted", ie); 222 } 223 } 224 attemptNumber++; 225 continue; 226 } 227 228 if (fetchResponse.getStatus() >= 300 229 && fetchResponse.getStatus() < 400 230 && fetchOptions.followRedirects) { 231 if (!fetchResponse.getHeaders().containsKey("Location")) { 232 throw new BoxSDKError( 233 "Redirect response missing Location header for " + fetchOptions.getUrl()); 234 } 235 URI originalUri = URI.create(fetchOptions.getUrl()); 236 URI redirectUri = URI.create(fetchResponse.getHeaders().get("Location")); 237 boolean sameOrigin = 238 originalUri.getHost().equals(redirectUri.getHost()) 239 && originalUri.getPort() == redirectUri.getPort() 240 && originalUri.getScheme().equals(redirectUri.getScheme()); 241 return fetch( 242 new FetchOptions.Builder(fetchResponse.getHeaders().get("Location"), "GET") 243 .responseFormat(fetchOptions.getResponseFormat()) 244 .auth(sameOrigin ? fetchOptions.getAuth() : null) 245 .networkSession(networkSession) 246 .build()); 247 } 248 249 if (fetchResponse.getStatus() >= 200 && fetchResponse.getStatus() < 400) { 250 return fetchResponse; 251 } 252 253 throwOnUnsuccessfulResponse( 254 request, 255 fetchResponse, 256 rawResponseBody, 257 exceptionThrown, 258 networkSession.getDataSanitizer()); 259 } 260 } 261 262 private static Request prepareRequest( 263 FetchOptions options, boolean reauthenticate, NetworkSession networkSession) { 264 Request.Builder requestBuilder = new Request.Builder().url(options.getUrl()); 265 Headers headers = prepareHeaders(options, reauthenticate, networkSession); 266 HttpUrl url = prepareUrl(options); 267 RequestBody body = prepareRequestBody(options); 268 269 requestBuilder.headers(headers); 270 requestBuilder.url(url); 271 requestBuilder.method(options.getMethod().toUpperCase(Locale.ROOT), body); 272 return requestBuilder.build(); 273 } 274 275 private static Headers prepareHeaders( 276 FetchOptions options, boolean reauthenticate, NetworkSession networkSession) { 277 Headers.Builder headersBuilder = new Headers.Builder(); 278 279 networkSession.getAdditionalHeaders().forEach(headersBuilder::add); 280 281 if (options.getHeaders() != null) { 282 options.getHeaders().forEach(headersBuilder::add); 283 } 284 if (options.getAuth() != null) { 285 if (reauthenticate) { 286 options.getAuth().refreshToken(networkSession); 287 } 288 headersBuilder.add( 289 "Authorization", options.getAuth().retrieveAuthorizationHeader(networkSession)); 290 } 291 headersBuilder.add("User-Agent", USER_AGENT_HEADER); 292 headersBuilder.add("X-Box-UA", X_BOX_UA_HEADER); 293 return headersBuilder.build(); 294 } 295 296 private static HttpUrl prepareUrl(FetchOptions options) { 297 298 HttpUrl baseUrl = HttpUrl.parse(options.getUrl()); 299 if (baseUrl == null) { 300 throw new IllegalArgumentException("Invalid URL " + options.getUrl()); 301 } 302 HttpUrl.Builder urlBuilder = baseUrl.newBuilder(); 303 if (options.getParams() != null) { 304 options.getParams().forEach(urlBuilder::addQueryParameter); 305 } 306 return urlBuilder.build(); 307 } 308 309 private static RequestBody prepareRequestBody(FetchOptions options) { 310 if (options.getMethod().equalsIgnoreCase("GET")) { 311 return null; 312 } 313 String contentType = options.getContentType(); 314 MediaType mediaType = MediaType.parse(contentType); 315 switch (contentType) { 316 case "application/json": 317 case "application/json-patch+json": 318 return options.getData() != null 319 ? RequestBody.create(sdToJson(options.getData()), mediaType) 320 : RequestBody.create("", mediaType); 321 case "application/x-www-form-urlencoded": 322 return options.getData() != null 323 ? RequestBody.create(sdToUrlParams(options.getData()), mediaType) 324 : RequestBody.create("", mediaType); 325 case "multipart/form-data": 326 MultipartBody.Builder bodyBuilder = new MultipartBody.Builder().setType(MultipartBody.FORM); 327 for (MultipartItem part : options.multipartData) { 328 if (part.getData() != null) { 329 bodyBuilder.addFormDataPart(part.getPartName(), sdToJson(part.getData())); 330 } else { 331 bodyBuilder.addFormDataPart( 332 part.getPartName(), 333 part.getFileName() != null ? part.getFileName() : "file", 334 createMultipartRequestBody(part)); 335 } 336 } 337 return bodyBuilder.build(); 338 case "application/octet-stream": 339 return RequestBody.create(readByteStream(options.getFileStream()), mediaType); 340 default: 341 throw new IllegalArgumentException("Unsupported content type " + contentType); 342 } 343 } 344 345 protected Call createNewCall(Request request) { 346 return this.httpClient.newCall(request); 347 } 348 349 private Response executeOnClient(Request request) throws IOException { 350 return createNewCall(request).execute(); 351 } 352 353 private static JsonNode readJsonFromRawBody(String rawResponseBody) { 354 if (rawResponseBody == null) { 355 return null; 356 } 357 358 try { 359 return jsonToSerializedData(rawResponseBody); 360 } catch (Exception e) { 361 return null; 362 } 363 } 364 365 private static void throwOnUnsuccessfulResponse( 366 Request request, 367 FetchResponse fetchResponse, 368 String rawResponseBody, 369 Exception exceptionThrown, 370 DataSanitizer dataSanitizer) { 371 if (fetchResponse.getStatus() == 0 && exceptionThrown != null) { 372 throw new BoxSDKError(exceptionThrown.getMessage(), exceptionThrown); 373 } 374 try { 375 throw BoxAPIError.fromAPICall(request, fetchResponse, rawResponseBody, dataSanitizer); 376 } finally { 377 try { 378 if (fetchResponse.getContent() != null) { 379 fetchResponse.getContent().close(); 380 } 381 } catch (IOException ignored) { 382 } 383 } 384 } 385 386 private static int getRetryAfterTimeInSeconds(int attemptNumber, String retryAfterHeader) { 387 388 if (retryAfterHeader != null) { 389 return Integer.parseInt(retryAfterHeader); 390 } 391 392 double minWindow = 1 - RANDOM_FACTOR; 393 double maxWindow = 1 + RANDOM_FACTOR; 394 double jitter = (Math.random() * (maxWindow - minWindow)) + minWindow; 395 return (int) (Math.pow(2, attemptNumber) * BASE_TIMEOUT * jitter); 396 } 397 398 public static RequestBody createMultipartRequestBody(MultipartItem part) { 399 return new RequestBody() { 400 @Override 401 public MediaType contentType() { 402 if (part.contentType != null) { 403 return MediaType.parse(part.contentType); 404 } 405 return MediaType.parse("application/octet-stream"); 406 } 407 408 @Override 409 public void writeTo(BufferedSink sink) throws IOException { 410 try (Source source = Okio.source(part.getFileStream())) { 411 sink.writeAll(source); 412 } 413 } 414 }; 415 } 416 417 public static OkHttpClient.Builder getDefaultOkHttpClientBuilder() { 418 return new OkHttpClient.Builder() 419 .followSslRedirects(true) 420 .followRedirects(false) 421 .connectionSpecs(singletonList(MODERN_TLS)) 422 .retryOnConnectionFailure(false); 423 } 424}