001/*
002 * Copyright (c) 2010-2026 Mark Allen, Norbert Bartels.
003 *
004 * Permission is hereby granted, free of charge, to any person obtaining a copy
005 * of this software and associated documentation files (the "Software"), to deal
006 * in the Software without restriction, including without limitation the rights
007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
008 * copies of the Software, and to permit persons to whom the Software is
009 * furnished to do so, subject to the following conditions:
010 *
011 * The above copyright notice and this permission notice shall be included in
012 * all copies or substantial portions of the Software.
013 *
014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
020 * THE SOFTWARE.
021 */
022package com.restfb;
023
024import static com.restfb.logging.RestFBLogger.HTTP_LOGGER;
025
026import java.io.ByteArrayOutputStream;
027import java.io.Closeable;
028import java.io.IOException;
029import java.io.InputStream;
030import java.io.OutputStream;
031import java.net.URI;
032import java.net.URISyntaxException;
033import java.net.URL;
034import java.net.http.HttpClient;
035import java.net.http.HttpHeaders;
036import java.net.http.HttpRequest;
037import java.net.http.HttpResponse;
038import java.net.http.HttpRequest.BodyPublisher;
039import java.net.http.HttpRequest.BodyPublishers;
040import java.net.http.HttpResponse.BodyHandlers;
041import java.time.Duration;
042import java.util.*;
043import java.util.Objects;
044
045import com.restfb.request.MultipartFormBodyPublisher;
046import com.restfb.request.TempFileBodyPublisher;
047import com.restfb.types.FacebookReelAttachment;
048import com.restfb.util.StringUtils;
049import com.restfb.util.UrlUtils;
050
051/**
052 * Default implementation of a service that sends HTTP requests to the Facebook API endpoint.
053 *
054 * @author <a href="http://restfb.com">Mark Allen</a>
055 */
056public class DefaultWebRequestor implements WebRequestor {
057  private static final String CONTENT_TYPE_JSON = "application/json";
058  private static final String CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded";
059
060  /**
061   * Arbitrary unique boundary marker for multipart {@code POST}s.
062   */
063  private static final String MULTIPART_BOUNDARY = "**boundarystringwhichwill**neverbeencounteredinthewild**";
064
065  /**
066   * Default buffer size for multipart {@code POST}s.
067   */
068  private static final int MULTIPART_DEFAULT_BUFFER_SIZE = 8192;
069
070  /**
071   * By default, how long should we wait for a response (in ms)?
072   */
073  private static final int DEFAULT_READ_TIMEOUT_IN_MS = 180000;
074
075  public static final String HEADER_CONTENT_TYPE = "Content-Type";
076
077  /**
078   * By default, this is true, to prevent breaking existing usage
079   */
080  private boolean autocloseBinaryAttachmentStream = true;
081
082  private final HttpClient httpClient;
083
084  public DefaultWebRequestor() {
085    this.httpClient = createHttpClientBuilder().build();
086  }
087
088  protected DefaultWebRequestor(HttpClient httpClient) {
089    this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null");
090  }
091
092  protected HttpClient getHttpClient() {
093    return httpClient;
094  }
095
096  protected enum HttpMethod {
097    GET, DELETE, POST
098  }
099
100  @Override
101  public Response executeGet(Request request) throws IOException {
102    return execute(HttpMethod.GET, request);
103  }
104
105  private Response executeReelUpload(Request request) throws IOException {
106    Optional<FacebookReelAttachment> reelOpt = request.getReel();
107
108    if (reelOpt.isEmpty()) {
109      throw new IllegalArgumentException("Try uploading reel with corrupt request");
110    }
111
112    FacebookReelAttachment reel = reelOpt.get();
113
114    logRequestAndAttachmentOnDebug(request, request.getBinaryAttachments());
115
116    TempFileBodyPublisher bodyPublisher = null;
117
118    try {
119      HttpRequest.Builder builder = openConnection(new URL(request.getUrl()));
120      builder.timeout(Duration.ofMillis(DEFAULT_READ_TIMEOUT_IN_MS));
121      initHeaderAccessToken(builder, request);
122      fillReelHeader(builder, reel);
123
124      customizeRequest(builder, request, HttpMethod.POST);
125
126      BodyPublisher publisher = BodyPublishers.noBody();
127      if (reel.isBinary()) {
128        bodyPublisher = new TempFileBodyPublisher();
129        try (OutputStream outputStream = bodyPublisher.outputStream()) {
130          write(reel.getData(), outputStream, MULTIPART_DEFAULT_BUFFER_SIZE);
131        }
132        publisher = bodyPublisher.build();
133      }
134
135      HttpRequest httpRequest = builder.POST(publisher).build();
136      return sendRequest(httpRequest);
137    } finally {
138      closeAttachmentsOnAutoClose(request.getBinaryAttachments());
139      closeQuietly(bodyPublisher);
140    }
141  }
142
143  private void fillReelHeader(HttpRequest.Builder builder, FacebookReelAttachment reel) {
144    if (reel.isBinary()) {
145      builder.header("offset", "0");
146      builder.header("file_size", String.valueOf(reel.getFileSizeInBytes()));
147    } else {
148      builder.header("file_url", reel.getReelUrl());
149    }
150  }
151
152  @Override
153  public Response executePost(Request request) throws IOException {
154    // special handling for reel upload
155    if (request.isReelUpload()) {
156      return executeReelUpload(request);
157    }
158
159    List<BinaryAttachment> binaryAttachments = request.getBinaryAttachments();
160
161    logRequestAndAttachmentOnDebug(request, binaryAttachments);
162
163    MultipartFormBodyPublisher multipartBodyPublisher = null;
164
165    try {
166      String url = buildPostUrl(request, binaryAttachments);
167      HttpRequest.Builder builder = openConnection(new URL(url));
168      builder.timeout(Duration.ofMillis(DEFAULT_READ_TIMEOUT_IN_MS));
169
170      initHeaderAccessToken(builder, request);
171
172      BodyPublisher publisher;
173      if (!binaryAttachments.isEmpty()) {
174        setMultipartRequestProperties(builder);
175        multipartBodyPublisher = new MultipartFormBodyPublisher(MULTIPART_BOUNDARY, MULTIPART_DEFAULT_BUFFER_SIZE);
176        multipartBodyPublisher.addAttachments(binaryAttachments);
177        publisher = multipartBodyPublisher.build();
178      } else {
179        if (request.hasBody()) {
180          setJsonRequestProperties(builder);
181        } else {
182          setFormUrlEncodedRequestProperties(builder);
183        }
184        String payload =
185            request.hasBody() ? request.getBody().getData() : Optional.ofNullable(request.getParameters()).orElse("");
186        publisher = BodyPublishers.ofString(payload, StringUtils.ENCODING_CHARSET);
187      }
188
189      customizeRequest(builder, request, HttpMethod.POST);
190
191      HttpRequest httpRequest = builder.POST(publisher).build();
192      return sendRequest(httpRequest);
193    } finally {
194      closeAttachmentsOnAutoClose(binaryAttachments);
195      closeQuietly(multipartBodyPublisher);
196    }
197  }
198
199  private void setMultipartRequestProperties(HttpRequest.Builder builder) {
200    builder.header(HEADER_CONTENT_TYPE, "multipart/form-data;boundary=" + MULTIPART_BOUNDARY);
201  }
202
203  private void setJsonRequestProperties(HttpRequest.Builder builder) {
204    builder.header(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON);
205  }
206
207  private void setFormUrlEncodedRequestProperties(HttpRequest.Builder builder) {
208    builder.header(HEADER_CONTENT_TYPE, CONTENT_TYPE_FORM_URLENCODED);
209  }
210
211  private String buildPostUrl(Request request, List<BinaryAttachment> binaryAttachments) {
212    return request.getUrl()
213        + ((!binaryAttachments.isEmpty() || request.hasBody()) ? "?" + request.getParameters() : "");
214  }
215
216  private static void logRequestAndAttachmentOnDebug(Request request, List<BinaryAttachment> binaryAttachments) {
217    if (HTTP_LOGGER.isDebugEnabled()) {
218      HTTP_LOGGER.debug("Executing a POST to " + request.getUrl() + " with parameters "
219          + (!binaryAttachments.isEmpty() ? "" : "(sent in request body): ")
220          + UrlUtils.urlDecode(request.getParameters())
221          + (!binaryAttachments.isEmpty() ? " and " + binaryAttachments.size() + " binary attachment[s]." : ""));
222    }
223  }
224
225  private void closeAttachmentsOnAutoClose(List<BinaryAttachment> binaryAttachments) {
226    if (autocloseBinaryAttachmentStream && !binaryAttachments.isEmpty()) {
227      binaryAttachments.stream().filter(BinaryAttachment::hasBinaryData).map(BinaryAttachment::getData)
228        .forEach(this::closeQuietly);
229    }
230  }
231
232  protected void initHeaderAccessToken(HttpRequest.Builder builder, Request request) {
233    if (request.isReelUpload()) {
234      builder.header("Authorization", "OAuth " + request.getHeaderAccessToken());
235    } else if (request.hasHeaderAccessToken()) {
236      builder.header("Authorization", "Bearer " + request.getHeaderAccessToken());
237    }
238  }
239
240  /**
241   * Given a {@code url}, opens and returns a connection to it.
242   * <p>
243   * If you'd like to pipe your connection through a proxy, this is the place to do so.
244   *
245   * @param url
246   *          The URL to connect to.
247   * @return A connection to the URL.
248   * @throws IOException
249   *           If an error occurs while establishing the connection.
250   * @since 1.6.3
251   */
252  protected HttpRequest.Builder openConnection(URL url) throws IOException {
253    try {
254      URI uri = url.toURI();
255      return HttpRequest.newBuilder(uri);
256    } catch (URISyntaxException e) {
257      throw new IOException("Invalid URL", e);
258    }
259  }
260
261  /**
262   * Factory for the HTTP client builder used by this requestor.
263   *
264   * @return pre-configured {@link HttpClient.Builder}
265   */
266  protected HttpClient.Builder createHttpClientBuilder() {
267    return HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL);
268  }
269
270  /**
271   * Hook method that lets subclasses tweak the {@link HttpRequest.Builder} before the request is sent.
272   * <p>
273   * Typical customizations include adding extra headers or adjusting timeouts per request type. This implementation is
274   * a no-op.
275   * </p>
276   *
277   * @param builder
278   *          the builder for the outbound request
279   * @param request
280   *          the logical RestFB request that triggered this HTTP call
281   * @param httpMethod
282   *          the HTTP method used for the call
283   */
284  protected void customizeRequest(HttpRequest.Builder builder, Request request, HttpMethod httpMethod) {
285    // This implementation is a no-op
286  }
287
288  /**
289   * Attempts to cleanly close a resource, swallowing any exceptions that might occur since there's no way to recover
290   * anyway.
291   * <p>
292   * It's OK to pass {@code null} in, this method will no-op in that case.
293   *
294   * @param closeable
295   *          The resource to close.
296   */
297  protected void closeQuietly(Closeable closeable) {
298    if (closeable != null) {
299      try {
300        closeable.close();
301      } catch (Exception t) {
302        HTTP_LOGGER.warn("Unable to close {}: ", closeable, t);
303      }
304    }
305  }
306
307  /**
308   * Writes the contents of the {@code source} stream to the {@code destination} stream using the given
309   * {@code bufferSize}.
310   *
311   * @param source
312   *          The source stream to copy from.
313   * @param destination
314   *          The destination stream to copy to.
315   * @param bufferSize
316   *          The size of the buffer to use during the copy operation.
317   * @throws IOException
318   *           If an error occurs when reading from {@code source} or writing to {@code destination}.
319   * @throws NullPointerException
320   *           If either {@code source} or @{code destination} is {@code null}.
321   */
322  protected void write(InputStream source, OutputStream destination, int bufferSize) throws IOException {
323    if (source == null || destination == null) {
324      throw new IllegalArgumentException("Must provide non-null source and destination streams.");
325    }
326
327    int read;
328    byte[] chunk = new byte[bufferSize];
329    while ((read = source.read(chunk)) > 0)
330      destination.write(chunk, 0, read);
331  }
332
333  /**
334   * Creates the form field name for the binary attachment filename by stripping off the file extension - for example,
335   * the filename "test.png" would return "test".
336   *
337   * @param binaryAttachment
338   *          The binary attachment for which to create the form field name.
339   * @return The form field name for the given binary attachment.
340   */
341  /**
342   * returns if the binary attachment stream is closed automatically
343   *
344   * @since 1.7.0
345   * @return {@code true} if the binary stream should be closed automatically, {@code false} otherwise
346   */
347  public boolean isAutocloseBinaryAttachmentStream() {
348    return autocloseBinaryAttachmentStream;
349  }
350
351  /**
352   * define if the binary attachment stream is closed automatically after sending the content to facebook
353   *
354   * @since 1.7.0
355   * @param autocloseBinaryAttachmentStream
356   *          {@code true} if the {@link BinaryAttachment} stream should be closed automatically, {@code false}
357   *          otherwise
358   */
359  public void setAutocloseBinaryAttachmentStream(boolean autocloseBinaryAttachmentStream) {
360    this.autocloseBinaryAttachmentStream = autocloseBinaryAttachmentStream;
361  }
362
363  @Override
364  public Response executeDelete(Request request) throws IOException {
365    return execute(HttpMethod.DELETE, request);
366  }
367
368  private Response execute(HttpMethod httpMethod, Request request) throws IOException {
369    HTTP_LOGGER.debug("Making a {} request to {} with parameters {}", httpMethod.name(), request.getUrl(),
370      request.getParameters());
371
372    HttpRequest.Builder builder = openConnection(new URL(request.getFullUrl()));
373    builder.timeout(Duration.ofMillis(DEFAULT_READ_TIMEOUT_IN_MS));
374
375    initHeaderAccessToken(builder, request);
376    customizeRequest(builder, request, httpMethod);
377
378    switch (httpMethod) {
379    case GET:
380      builder.GET();
381      break;
382    case DELETE:
383      builder.DELETE();
384      break;
385    default:
386      throw new IllegalArgumentException("Unsupported httpMethod used");
387    }
388
389    HttpRequest httpRequest = builder.build();
390    return sendRequest(httpRequest);
391  }
392
393  private Response sendRequest(HttpRequest httpRequest) throws IOException {
394    try {
395      HttpResponse<InputStream> httpResponse = getHttpClient().send(httpRequest, BodyHandlers.ofInputStream());
396      Map<String, List<String>> headers = Collections.unmodifiableMap(toHeaderMap(httpResponse.headers()));
397      HTTP_LOGGER.trace("Response headers: {}", headers);
398      return createResponse(httpResponse, headers);
399    } catch (InterruptedException e) {
400      Thread.currentThread().interrupt();
401      throw new IOException("Interrupted while making request", e);
402    }
403  }
404
405  private byte[] readResponseBody(HttpResponse<InputStream> httpResponse) throws IOException {
406    InputStream responseBody = httpResponse.body();
407    if (responseBody == null) {
408      return new byte[0];
409    }
410
411    long expectedLength = httpResponse.headers().firstValueAsLong("Content-Length").orElse(-1L);
412    long totalRead = 0;
413    byte[] buffer = new byte[8192];
414
415    try (InputStream bodyStream = responseBody; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
416      int read;
417      while ((read = bodyStream.read(buffer)) != -1) {
418        output.write(buffer, 0, read);
419        totalRead += read;
420      }
421
422      if (expectedLength >= 0 && expectedLength != totalRead) {
423        throw new IOException("Incomplete response body: expected " + expectedLength + " bytes but read " + totalRead);
424      }
425
426      return output.toByteArray();
427    } catch (IOException ioe) {
428      throw new IOException("Incomplete response body", ioe);
429    }
430  }
431
432  protected Response createResponse(HttpResponse<InputStream> httpResponse, Map<String, List<String>> headers)
433      throws IOException {
434    byte[] body = readResponseBody(httpResponse);
435    Response response = new Response(httpResponse.statusCode(), StringUtils.toString(body), null, headers);
436    HTTP_LOGGER.debug("Facebook responded with {}", response);
437    return response;
438  }
439
440  private Map<String, List<String>> toHeaderMap(HttpHeaders headers) {
441    Map<String, List<String>> result = new LinkedHashMap<>();
442    headers.map().forEach((k, v) -> result.put(k, Collections.unmodifiableList(v)));
443    return result;
444  }
445
446}