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}