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}