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