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}