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