001/*
002 * Copyright (c) 2010-2024 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.Closeable;
027import java.io.IOException;
028import java.io.InputStream;
029import java.io.OutputStream;
030import java.net.HttpURLConnection;
031import java.net.URL;
032import java.util.*;
033import java.util.function.BiConsumer;
034
035import com.restfb.types.FacebookReelAttachment;
036import com.restfb.util.StringUtils;
037import com.restfb.util.UrlUtils;
038
039/**
040 * Default implementation of a service that sends HTTP requests to the Facebook API endpoint.
041 * 
042 * @author <a href="http://restfb.com">Mark Allen</a>
043 */
044public class DefaultWebRequestor implements WebRequestor {
045  /**
046   * Arbitrary unique boundary marker for multipart {@code POST}s.
047   */
048  private static final String MULTIPART_BOUNDARY = "**boundarystringwhichwill**neverbeencounteredinthewild**";
049
050  /**
051   * Line separator for multipart {@code POST}s.
052   */
053  private static final String MULTIPART_CARRIAGE_RETURN_AND_NEWLINE = "\r\n";
054
055  /**
056   * Hyphens for multipart {@code POST}s.
057   */
058  private static final String MULTIPART_TWO_HYPHENS = "--";
059
060  /**
061   * Default buffer size for multipart {@code POST}s.
062   */
063  private static final int MULTIPART_DEFAULT_BUFFER_SIZE = 8192;
064
065  /**
066   * By default, how long should we wait for a response (in ms)?
067   */
068  private static final int DEFAULT_READ_TIMEOUT_IN_MS = 180000;
069
070  private Map<String, List<String>> currentHeaders;
071
072  private DebugHeaderInfo debugHeaderInfo;
073
074  /**
075   * By default this is true, to prevent breaking existing usage
076   */
077  private boolean autocloseBinaryAttachmentStream = true;
078
079  protected enum HttpMethod {
080    GET, DELETE, POST
081  }
082
083  @Override
084  public Response executeGet(Request request) throws IOException {
085    return execute(HttpMethod.GET, request);
086  }
087
088  private Response executeReelUpload(Request request) throws IOException {
089    Optional<FacebookReelAttachment> reelOpt = request.getReel();
090
091    if (!reelOpt.isPresent()) {
092      throw new IllegalArgumentException("Try uploading reel with corrupt request");
093    }
094
095    FacebookReelAttachment reel = reelOpt.get();
096
097    logRequestAndAttachmentOnDebug(request, request.getBinaryAttachments());
098
099    HttpURLConnection httpUrlConnection = null;
100
101    try {
102      String url = request.getUrl();
103      httpUrlConnection = openConnection(new URL(url));
104      httpUrlConnection.setReadTimeout(DEFAULT_READ_TIMEOUT_IN_MS);
105
106      // Allow subclasses to customize the connection if they'd like to - set
107      // their own headers, timeouts, etc.
108      customizeConnection(httpUrlConnection);
109
110      httpUrlConnection.setRequestMethod(HttpMethod.POST.name());
111      httpUrlConnection.setDoOutput(true);
112      httpUrlConnection.setUseCaches(false);
113
114      httpUrlConnection.setRequestProperty("Connection", "Keep-Alive");
115
116      initHeaderAccessToken(httpUrlConnection, request);
117      fillReelHeader(httpUrlConnection, reel);
118
119      httpUrlConnection.connect();
120
121      if (reel.isBinary()) {
122        try (OutputStream outputStream = httpUrlConnection.getOutputStream()) {
123          write(reel.getData(), outputStream, MULTIPART_DEFAULT_BUFFER_SIZE);
124        }
125      }
126
127      HTTP_LOGGER.debug("Response headers: {}", httpUrlConnection.getHeaderFields());
128      fillHeaderAndDebugInfo(httpUrlConnection);
129      return fetchResponse(httpUrlConnection);
130    } finally {
131      closeAttachmentsOnAutoClose(request.getBinaryAttachments());
132      closeQuietly(httpUrlConnection);
133    }
134  }
135
136  private void fillReelHeader(HttpURLConnection httpUrlConnection, FacebookReelAttachment reel) {
137    if (reel.isBinary()) {
138      httpUrlConnection.setRequestProperty("offset", "0");
139      httpUrlConnection.setRequestProperty("file_size", String.valueOf(reel.getFileSizeInBytes()));
140    } else {
141      httpUrlConnection.setRequestProperty("file_url", reel.getReelUrl());
142    }
143  }
144
145  @Override
146  public Response executePost(Request request) throws IOException {
147    // special handling for reel upload
148    if (request.isReelUpload()) {
149      return executeReelUpload(request);
150    }
151
152    List<BinaryAttachment> binaryAttachments = request.getBinaryAttachments();
153
154    logRequestAndAttachmentOnDebug(request, binaryAttachments);
155
156    HttpURLConnection httpUrlConnection = null;
157
158    try {
159      String url = buildPostUrl(request, binaryAttachments);
160      httpUrlConnection = openConnection(new URL(url));
161      httpUrlConnection.setReadTimeout(DEFAULT_READ_TIMEOUT_IN_MS);
162
163      // Allow subclasses to customize the connection if they'd like to - set
164      // their own headers, timeouts, etc.
165      customizeConnection(httpUrlConnection);
166
167      httpUrlConnection.setRequestMethod(HttpMethod.POST.name());
168      httpUrlConnection.setDoOutput(true);
169      httpUrlConnection.setUseCaches(false);
170
171      initHeaderAccessToken(httpUrlConnection, request);
172
173      if (!binaryAttachments.isEmpty()) {
174        setMultipartRequestProperties(httpUrlConnection);
175      }
176
177      if (request.hasBody()) {
178        setJsonRequestProperties(httpUrlConnection);
179      }
180
181      httpUrlConnection.connect();
182
183      try (OutputStream outputStream = httpUrlConnection.getOutputStream()) {
184
185        // If we have binary attachments, the body is just the attachments and the
186        // other parameters are passed in via the URL.
187        // Otherwise the body is the URL parameter string.
188        if (!binaryAttachments.isEmpty()) {
189          writeBinaryAttachments(binaryAttachments, outputStream);
190        } else {
191          writeRequestToOutputStream(request, outputStream);
192        }
193      }
194
195      HTTP_LOGGER.debug("Response headers: {}", httpUrlConnection.getHeaderFields());
196      fillHeaderAndDebugInfo(httpUrlConnection);
197      return fetchResponse(httpUrlConnection);
198    } finally {
199      closeAttachmentsOnAutoClose(binaryAttachments);
200      closeQuietly(httpUrlConnection);
201    }
202  }
203
204  private void writeBinaryAttachments(List<BinaryAttachment> binaryAttachments, OutputStream outputStream) throws IOException {
205    for (BinaryAttachment binaryAttachment : binaryAttachments) {
206      writeBinaryAttachmentToOutputStream(binaryAttachment, outputStream);
207    }
208  }
209
210  private void setJsonRequestProperties(HttpURLConnection httpUrlConnection) {
211    httpUrlConnection.setRequestProperty("Content-Type", "application/json");
212  }
213
214  private void setMultipartRequestProperties(HttpURLConnection httpUrlConnection) {
215    httpUrlConnection.setRequestProperty("Connection", "Keep-Alive");
216    httpUrlConnection.setRequestProperty("Content-Type", "multipart/form-data;boundary=" + MULTIPART_BOUNDARY);
217  }
218
219  private String buildPostUrl(Request request, List<BinaryAttachment> binaryAttachments) {
220    return request.getUrl() + ((!binaryAttachments.isEmpty() || request.hasBody()) ? "?" + request.getParameters() : "");
221  }
222
223  private void writeBinaryAttachmentToOutputStream(BinaryAttachment binaryAttachment, OutputStream outputStream) throws IOException {
224    StringBuilder formData = createBinaryAttachmentFormData(binaryAttachment);
225    outputStream.write(formData.toString().getBytes(StringUtils.ENCODING_CHARSET));
226    write(binaryAttachment.getData(), outputStream, MULTIPART_DEFAULT_BUFFER_SIZE);
227    outputStream.write((MULTIPART_CARRIAGE_RETURN_AND_NEWLINE + MULTIPART_TWO_HYPHENS + MULTIPART_BOUNDARY
228            + MULTIPART_TWO_HYPHENS + MULTIPART_CARRIAGE_RETURN_AND_NEWLINE).getBytes(StringUtils.ENCODING_CHARSET));
229  }
230
231  private static void writeRequestToOutputStream(Request request, OutputStream outputStream) throws IOException {
232    if (request.hasBody()) {
233      outputStream.write(request.getBody().getData().getBytes(StringUtils.ENCODING_CHARSET));
234    } else {
235      outputStream.write(request.getParameters().getBytes(StringUtils.ENCODING_CHARSET));
236    }
237  }
238
239  private static void logRequestAndAttachmentOnDebug(Request request, List<BinaryAttachment> binaryAttachments) {
240    if (HTTP_LOGGER.isDebugEnabled()) {
241      HTTP_LOGGER.debug("Executing a POST to " + request.getUrl() + " with parameters "
242              + (!binaryAttachments.isEmpty() ? "" : "(sent in request body): ")
243              + UrlUtils.urlDecode(request.getParameters())
244              + (!binaryAttachments.isEmpty() ? " and " + binaryAttachments.size() + " binary attachment[s]." : ""));
245    }
246  }
247
248  private StringBuilder createBinaryAttachmentFormData(BinaryAttachment binaryAttachment) {
249    StringBuilder stringBuilder = new StringBuilder();
250    stringBuilder.append(MULTIPART_TWO_HYPHENS).append(MULTIPART_BOUNDARY).append(MULTIPART_CARRIAGE_RETURN_AND_NEWLINE)
251      .append("Content-Disposition: form-data; name=\"").append(createFormFieldName(binaryAttachment))
252      .append("\"; filename=\"").append(binaryAttachment.getFilename()).append("\"");
253
254    stringBuilder.append(MULTIPART_CARRIAGE_RETURN_AND_NEWLINE).append("Content-Type: ")
255      .append(binaryAttachment.getContentType());
256
257    stringBuilder.append(MULTIPART_CARRIAGE_RETURN_AND_NEWLINE).append(MULTIPART_CARRIAGE_RETURN_AND_NEWLINE);
258    return stringBuilder;
259  }
260
261  private void closeAttachmentsOnAutoClose(List<BinaryAttachment> binaryAttachments) {
262    if (autocloseBinaryAttachmentStream && !binaryAttachments.isEmpty()) {
263      binaryAttachments.stream().filter(BinaryAttachment::hasBinaryData).map(BinaryAttachment::getData).forEach(this::closeQuietly);
264    }
265  }
266
267  protected void initHeaderAccessToken(HttpURLConnection httpUrlConnection, Request request) {
268    if (request.isReelUpload()) {
269      httpUrlConnection.setRequestProperty("Authorization", "OAuth " + request.getHeaderAccessToken());
270    } else if (request.hasHeaderAccessToken()) {
271      httpUrlConnection.setRequestProperty("Authorization", "Bearer " + request.getHeaderAccessToken());
272    }
273  }
274
275  /**
276   * Given a {@code url}, opens and returns a connection to it.
277   * <p>
278   * If you'd like to pipe your connection through a proxy, this is the place to do so.
279   * 
280   * @param url
281   *          The URL to connect to.
282   * @return A connection to the URL.
283   * @throws IOException
284   *           If an error occurs while establishing the connection.
285   * @since 1.6.3
286   */
287  protected HttpURLConnection openConnection(URL url) throws IOException {
288    return (HttpURLConnection) url.openConnection();
289  }
290
291  /**
292   * Hook method which allows subclasses to easily customize the {@code connection}s created by
293   * {@link #executeGet(com.restfb.WebRequestor.Request)} and {@link #executePost(com.restfb.WebRequestor.Request)} -
294   * for example, setting a custom read timeout or request header.
295   * <p>
296   * This implementation is a no-op.
297   * 
298   * @param connection
299   *          The connection to customize.
300   */
301  protected void customizeConnection(HttpURLConnection connection) {
302    // This implementation is a no-op
303  }
304
305  /**
306   * Attempts to cleanly close a resource, swallowing any exceptions that might occur since there's no way to recover
307   * anyway.
308   * <p>
309   * It's OK to pass {@code null} in, this method will no-op in that case.
310   * 
311   * @param closeable
312   *          The resource to close.
313   */
314  protected void closeQuietly(Closeable closeable) {
315    if (closeable != null) {
316      try {
317        closeable.close();
318      } catch (Exception t) {
319        HTTP_LOGGER.warn("Unable to close {}: ", closeable, t);
320      }
321    }
322  }
323
324  /**
325   * Attempts to cleanly close an {@code HttpURLConnection}, swallowing any exceptions that might occur since there's no
326   * way to recover anyway.
327   * <p>
328   * It's OK to pass {@code null} in, this method will no-op in that case.
329   * 
330   * @param httpUrlConnection
331   *          The connection to close.
332   */
333  protected void closeQuietly(HttpURLConnection httpUrlConnection) {
334    try {
335      Optional.ofNullable(httpUrlConnection).ifPresent(HttpURLConnection::disconnect);
336    } catch (Exception t) {
337      HTTP_LOGGER.warn("Unable to disconnect {}: ", httpUrlConnection, t);
338    }
339  }
340
341  /**
342   * Writes the contents of the {@code source} stream to the {@code destination} stream using the given
343   * {@code bufferSize}.
344   * 
345   * @param source
346   *          The source stream to copy from.
347   * @param destination
348   *          The destination stream to copy to.
349   * @param bufferSize
350   *          The size of the buffer to use during the copy operation.
351   * @throws IOException
352   *           If an error occurs when reading from {@code source} or writing to {@code destination}.
353   * @throws NullPointerException
354   *           If either {@code source} or @{code destination} is {@code null}.
355   */
356  protected void write(InputStream source, OutputStream destination, int bufferSize) throws IOException {
357    if (source == null || destination == null) {
358      throw new IllegalArgumentException("Must provide non-null source and destination streams.");
359    }
360
361    int read;
362    byte[] chunk = new byte[bufferSize];
363    while ((read = source.read(chunk)) > 0)
364      destination.write(chunk, 0, read);
365  }
366
367  /**
368   * Creates the form field name for the binary attachment filename by stripping off the file extension - for example,
369   * the filename "test.png" would return "test".
370   * 
371   * @param binaryAttachment
372   *          The binary attachment for which to create the form field name.
373   * @return The form field name for the given binary attachment.
374   */
375  protected String createFormFieldName(BinaryAttachment binaryAttachment) {
376
377    if (binaryAttachment.getFieldName() != null) {
378      return binaryAttachment.getFieldName();
379    }
380
381    String name = binaryAttachment.getFilename();
382    return Optional.ofNullable(name).filter(f -> f.contains(".")).map(f -> f.substring(0, f.lastIndexOf('.')))
383      .orElse(name);
384  }
385
386  /**
387   * returns if the binary attachment stream is closed automatically
388   * 
389   * @since 1.7.0
390   * @return {@code true} if the binary stream should be closed automatically, {@code false} otherwise
391   */
392  public boolean isAutocloseBinaryAttachmentStream() {
393    return autocloseBinaryAttachmentStream;
394  }
395
396  /**
397   * define if the binary attachment stream is closed automatically after sending the content to facebook
398   * 
399   * @since 1.7.0
400   * @param autocloseBinaryAttachmentStream
401   *          {@code true} if the {@link BinaryAttachment} stream should be closed automatically, {@code false}
402   *          otherwise
403   */
404  public void setAutocloseBinaryAttachmentStream(boolean autocloseBinaryAttachmentStream) {
405    this.autocloseBinaryAttachmentStream = autocloseBinaryAttachmentStream;
406  }
407
408  /**
409   * access to the current response headers
410   * 
411   * @return the current reponse header map
412   */
413  public Map<String, List<String>> getCurrentHeaders() {
414    return currentHeaders;
415  }
416
417  @Override
418  public Response executeDelete(Request request) throws IOException {
419    return execute(HttpMethod.DELETE, request);
420  }
421
422  @Override
423  public DebugHeaderInfo getDebugHeaderInfo() {
424    return debugHeaderInfo;
425  }
426
427  private Response execute(HttpMethod httpMethod, Request request) throws IOException {
428    HTTP_LOGGER.debug("Making a {} request to {} with parameters {}", httpMethod.name(), request.getUrl(),
429      request.getParameters());
430
431    HttpURLConnection httpUrlConnection = null;
432
433    try {
434      httpUrlConnection = openConnection(new URL(request.getFullUrl()));
435      httpUrlConnection.setReadTimeout(DEFAULT_READ_TIMEOUT_IN_MS);
436      httpUrlConnection.setUseCaches(false);
437      httpUrlConnection.setRequestMethod(httpMethod.name());
438
439      initHeaderAccessToken(httpUrlConnection, request);
440
441      // Allow subclasses to customize the connection if they'd like to - set
442      // their own headers, timeouts, etc.
443      customizeConnection(httpUrlConnection);
444
445      httpUrlConnection.connect();
446
447      HTTP_LOGGER.trace("Response headers: {}", httpUrlConnection.getHeaderFields());
448      fillHeaderAndDebugInfo(httpUrlConnection);
449      return fetchResponse(httpUrlConnection);
450    } finally {
451      closeQuietly(httpUrlConnection);
452    }
453  }
454
455  protected void fillHeaderAndDebugInfo(HttpURLConnection httpUrlConnection) {
456    currentHeaders = Collections.unmodifiableMap(httpUrlConnection.getHeaderFields());
457
458    String usedApiVersion = StringUtils.trimToEmpty(httpUrlConnection.getHeaderField("facebook-api-version"));
459    HTTP_LOGGER.debug("Facebook used the API {} to answer your request", usedApiVersion);
460
461    Version usedVersion = Version.getVersionFromString(usedApiVersion);
462    DebugHeaderInfo.DebugHeaderInfoFactory factory =
463        DebugHeaderInfo.DebugHeaderInfoFactory.create().setVersion(usedVersion);
464
465    Arrays.stream(FbHeaderField.values()).forEach(f -> f.getPutHeader().accept(httpUrlConnection, factory));
466    debugHeaderInfo = factory.build();
467  }
468
469  protected Response fetchResponse(HttpURLConnection httpUrlConnection) throws IOException {
470    InputStream inputStream = null;
471    try {
472      inputStream = getInputStreamFromUrlConnection(httpUrlConnection);
473    } catch (IOException e) {
474      HTTP_LOGGER.warn("An error occurred while making a {} request to {}:", httpUrlConnection.getRequestMethod(),
475        httpUrlConnection.getURL(), e);
476    }
477
478    Response response = new Response(httpUrlConnection.getResponseCode(), StringUtils.fromInputStream(inputStream));
479    HTTP_LOGGER.debug("Facebook responded with {}", response);
480    return response;
481  }
482
483  private InputStream getInputStreamFromUrlConnection(HttpURLConnection httpUrlConnection) throws IOException {
484    return httpUrlConnection.getResponseCode() != HttpURLConnection.HTTP_OK ? httpUrlConnection.getErrorStream()
485        : httpUrlConnection.getInputStream();
486  }
487
488  private enum FbHeaderField {
489    X_FB_TRACE_ID((c, f) -> f.setTraceId(getHeaderOrEmpty(c, "x-fb-trace-id"))), //
490    X_FB_REV((c, f) -> f.setRev(getHeaderOrEmpty(c, "x-fb-rev"))), //
491    X_FB_DEBUG((c, f) -> f.setDebug(getHeaderOrEmpty(c, "x-fb-debug"))), //
492    X_APP_USAGE((c, f) -> f.setAppUsage(getHeaderOrEmpty(c, "x-app-usage"))), //
493    X_PAGE_USAGE((c, f) -> f.setPageUsage(getHeaderOrEmpty(c, "x-page-usage"))), //
494    X_AD_ACCOUNT_USAGE((c, f) -> f.setAdAccountUsage(getHeaderOrEmpty(c, "x-ad-account-usage"))), //
495    X_BUSINESS_USE_CASE_USAGE((c, f) -> f.setBusinessUseCaseUsage(getHeaderOrEmpty(c, "x-business-use-case-usage")));
496
497    private final BiConsumer<HttpURLConnection, DebugHeaderInfo.DebugHeaderInfoFactory> putHeader;
498
499    FbHeaderField(BiConsumer<HttpURLConnection, DebugHeaderInfo.DebugHeaderInfoFactory> headerFunction) {
500      this.putHeader = headerFunction;
501    }
502
503    public BiConsumer<HttpURLConnection, DebugHeaderInfo.DebugHeaderInfoFactory> getPutHeader() {
504      return putHeader;
505    }
506
507    private static String getHeaderOrEmpty(HttpURLConnection connection, String fieldName) {
508      return StringUtils.trimToEmpty(connection.getHeaderField(fieldName));
509    }
510  }
511
512}