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}