001/* 002 * Copyright (c) 2010-2026 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.ByteArrayOutputStream; 027import java.io.Closeable; 028import java.io.IOException; 029import java.io.InputStream; 030import java.io.OutputStream; 031import java.net.URI; 032import java.net.URISyntaxException; 033import java.net.URL; 034import java.net.http.HttpClient; 035import java.net.http.HttpHeaders; 036import java.net.http.HttpRequest; 037import java.net.http.HttpResponse; 038import java.net.http.HttpRequest.BodyPublisher; 039import java.net.http.HttpRequest.BodyPublishers; 040import java.net.http.HttpResponse.BodyHandlers; 041import java.time.Duration; 042import java.util.*; 043import java.util.Objects; 044 045import com.restfb.request.MultipartFormBodyPublisher; 046import com.restfb.request.TempFileBodyPublisher; 047import com.restfb.types.FacebookReelAttachment; 048import com.restfb.util.StringUtils; 049import com.restfb.util.UrlUtils; 050 051/** 052 * Default implementation of a service that sends HTTP requests to the Facebook API endpoint. 053 * 054 * @author <a href="http://restfb.com">Mark Allen</a> 055 */ 056public class DefaultWebRequestor implements WebRequestor { 057 private static final String CONTENT_TYPE_JSON = "application/json"; 058 private static final String CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"; 059 060 /** 061 * Arbitrary unique boundary marker for multipart {@code POST}s. 062 */ 063 private static final String MULTIPART_BOUNDARY = "**boundarystringwhichwill**neverbeencounteredinthewild**"; 064 065 /** 066 * Default buffer size for multipart {@code POST}s. 067 */ 068 private static final int MULTIPART_DEFAULT_BUFFER_SIZE = 8192; 069 070 /** 071 * By default, how long should we wait for a response (in ms)? 072 */ 073 private static final int DEFAULT_READ_TIMEOUT_IN_MS = 180000; 074 075 public static final String HEADER_CONTENT_TYPE = "Content-Type"; 076 077 /** 078 * By default, this is true, to prevent breaking existing usage 079 */ 080 private boolean autocloseBinaryAttachmentStream = true; 081 082 private final HttpClient httpClient; 083 084 public DefaultWebRequestor() { 085 this.httpClient = createHttpClientBuilder().build(); 086 } 087 088 protected DefaultWebRequestor(HttpClient httpClient) { 089 this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null"); 090 } 091 092 protected HttpClient getHttpClient() { 093 return httpClient; 094 } 095 096 protected enum HttpMethod { 097 GET, DELETE, POST 098 } 099 100 @Override 101 public Response executeGet(Request request) throws IOException { 102 return execute(HttpMethod.GET, request); 103 } 104 105 private Response executeReelUpload(Request request) throws IOException { 106 Optional<FacebookReelAttachment> reelOpt = request.getReel(); 107 108 if (reelOpt.isEmpty()) { 109 throw new IllegalArgumentException("Try uploading reel with corrupt request"); 110 } 111 112 FacebookReelAttachment reel = reelOpt.get(); 113 114 logRequestAndAttachmentOnDebug(request, request.getBinaryAttachments()); 115 116 TempFileBodyPublisher bodyPublisher = null; 117 118 try { 119 HttpRequest.Builder builder = openConnection(new URL(request.getUrl())); 120 builder.timeout(Duration.ofMillis(DEFAULT_READ_TIMEOUT_IN_MS)); 121 initHeaderAccessToken(builder, request); 122 fillReelHeader(builder, reel); 123 124 customizeRequest(builder, request, HttpMethod.POST); 125 126 BodyPublisher publisher = BodyPublishers.noBody(); 127 if (reel.isBinary()) { 128 bodyPublisher = new TempFileBodyPublisher(); 129 try (OutputStream outputStream = bodyPublisher.outputStream()) { 130 write(reel.getData(), outputStream, MULTIPART_DEFAULT_BUFFER_SIZE); 131 } 132 publisher = bodyPublisher.build(); 133 } 134 135 HttpRequest httpRequest = builder.POST(publisher).build(); 136 return sendRequest(httpRequest); 137 } finally { 138 closeAttachmentsOnAutoClose(request.getBinaryAttachments()); 139 closeQuietly(bodyPublisher); 140 } 141 } 142 143 private void fillReelHeader(HttpRequest.Builder builder, FacebookReelAttachment reel) { 144 if (reel.isBinary()) { 145 builder.header("offset", "0"); 146 builder.header("file_size", String.valueOf(reel.getFileSizeInBytes())); 147 } else { 148 builder.header("file_url", reel.getReelUrl()); 149 } 150 } 151 152 @Override 153 public Response executePost(Request request) throws IOException { 154 // special handling for reel upload 155 if (request.isReelUpload()) { 156 return executeReelUpload(request); 157 } 158 159 List<BinaryAttachment> binaryAttachments = request.getBinaryAttachments(); 160 161 logRequestAndAttachmentOnDebug(request, binaryAttachments); 162 163 MultipartFormBodyPublisher multipartBodyPublisher = null; 164 165 try { 166 String url = buildPostUrl(request, binaryAttachments); 167 HttpRequest.Builder builder = openConnection(new URL(url)); 168 builder.timeout(Duration.ofMillis(DEFAULT_READ_TIMEOUT_IN_MS)); 169 170 initHeaderAccessToken(builder, request); 171 172 BodyPublisher publisher; 173 if (!binaryAttachments.isEmpty()) { 174 setMultipartRequestProperties(builder); 175 multipartBodyPublisher = new MultipartFormBodyPublisher(MULTIPART_BOUNDARY, MULTIPART_DEFAULT_BUFFER_SIZE); 176 multipartBodyPublisher.addAttachments(binaryAttachments); 177 publisher = multipartBodyPublisher.build(); 178 } else { 179 if (request.hasBody()) { 180 setJsonRequestProperties(builder); 181 } else { 182 setFormUrlEncodedRequestProperties(builder); 183 } 184 String payload = 185 request.hasBody() ? request.getBody().getData() : Optional.ofNullable(request.getParameters()).orElse(""); 186 publisher = BodyPublishers.ofString(payload, StringUtils.ENCODING_CHARSET); 187 } 188 189 customizeRequest(builder, request, HttpMethod.POST); 190 191 HttpRequest httpRequest = builder.POST(publisher).build(); 192 return sendRequest(httpRequest); 193 } finally { 194 closeAttachmentsOnAutoClose(binaryAttachments); 195 closeQuietly(multipartBodyPublisher); 196 } 197 } 198 199 private void setMultipartRequestProperties(HttpRequest.Builder builder) { 200 builder.header(HEADER_CONTENT_TYPE, "multipart/form-data;boundary=" + MULTIPART_BOUNDARY); 201 } 202 203 private void setJsonRequestProperties(HttpRequest.Builder builder) { 204 builder.header(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON); 205 } 206 207 private void setFormUrlEncodedRequestProperties(HttpRequest.Builder builder) { 208 builder.header(HEADER_CONTENT_TYPE, CONTENT_TYPE_FORM_URLENCODED); 209 } 210 211 private String buildPostUrl(Request request, List<BinaryAttachment> binaryAttachments) { 212 return request.getUrl() 213 + ((!binaryAttachments.isEmpty() || request.hasBody()) ? "?" + request.getParameters() : ""); 214 } 215 216 private static void logRequestAndAttachmentOnDebug(Request request, List<BinaryAttachment> binaryAttachments) { 217 if (HTTP_LOGGER.isDebugEnabled()) { 218 HTTP_LOGGER.debug("Executing a POST to " + request.getUrl() + " with parameters " 219 + (!binaryAttachments.isEmpty() ? "" : "(sent in request body): ") 220 + UrlUtils.urlDecode(request.getParameters()) 221 + (!binaryAttachments.isEmpty() ? " and " + binaryAttachments.size() + " binary attachment[s]." : "")); 222 } 223 } 224 225 private void closeAttachmentsOnAutoClose(List<BinaryAttachment> binaryAttachments) { 226 if (autocloseBinaryAttachmentStream && !binaryAttachments.isEmpty()) { 227 binaryAttachments.stream().filter(BinaryAttachment::hasBinaryData).map(BinaryAttachment::getData) 228 .forEach(this::closeQuietly); 229 } 230 } 231 232 protected void initHeaderAccessToken(HttpRequest.Builder builder, Request request) { 233 if (request.isReelUpload()) { 234 builder.header("Authorization", "OAuth " + request.getHeaderAccessToken()); 235 } else if (request.hasHeaderAccessToken()) { 236 builder.header("Authorization", "Bearer " + request.getHeaderAccessToken()); 237 } 238 } 239 240 /** 241 * Given a {@code url}, opens and returns a connection to it. 242 * <p> 243 * If you'd like to pipe your connection through a proxy, this is the place to do so. 244 * 245 * @param url 246 * The URL to connect to. 247 * @return A connection to the URL. 248 * @throws IOException 249 * If an error occurs while establishing the connection. 250 * @since 1.6.3 251 */ 252 protected HttpRequest.Builder openConnection(URL url) throws IOException { 253 try { 254 URI uri = url.toURI(); 255 return HttpRequest.newBuilder(uri); 256 } catch (URISyntaxException e) { 257 throw new IOException("Invalid URL", e); 258 } 259 } 260 261 /** 262 * Factory for the HTTP client builder used by this requestor. 263 * 264 * @return pre-configured {@link HttpClient.Builder} 265 */ 266 protected HttpClient.Builder createHttpClientBuilder() { 267 return HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL); 268 } 269 270 /** 271 * Hook method that lets subclasses tweak the {@link HttpRequest.Builder} before the request is sent. 272 * <p> 273 * Typical customizations include adding extra headers or adjusting timeouts per request type. This implementation is 274 * a no-op. 275 * </p> 276 * 277 * @param builder 278 * the builder for the outbound request 279 * @param request 280 * the logical RestFB request that triggered this HTTP call 281 * @param httpMethod 282 * the HTTP method used for the call 283 */ 284 protected void customizeRequest(HttpRequest.Builder builder, Request request, HttpMethod httpMethod) { 285 // This implementation is a no-op 286 } 287 288 /** 289 * Attempts to cleanly close a resource, swallowing any exceptions that might occur since there's no way to recover 290 * anyway. 291 * <p> 292 * It's OK to pass {@code null} in, this method will no-op in that case. 293 * 294 * @param closeable 295 * The resource to close. 296 */ 297 protected void closeQuietly(Closeable closeable) { 298 if (closeable != null) { 299 try { 300 closeable.close(); 301 } catch (Exception t) { 302 HTTP_LOGGER.warn("Unable to close {}: ", closeable, t); 303 } 304 } 305 } 306 307 /** 308 * Writes the contents of the {@code source} stream to the {@code destination} stream using the given 309 * {@code bufferSize}. 310 * 311 * @param source 312 * The source stream to copy from. 313 * @param destination 314 * The destination stream to copy to. 315 * @param bufferSize 316 * The size of the buffer to use during the copy operation. 317 * @throws IOException 318 * If an error occurs when reading from {@code source} or writing to {@code destination}. 319 * @throws NullPointerException 320 * If either {@code source} or @{code destination} is {@code null}. 321 */ 322 protected void write(InputStream source, OutputStream destination, int bufferSize) throws IOException { 323 if (source == null || destination == null) { 324 throw new IllegalArgumentException("Must provide non-null source and destination streams."); 325 } 326 327 int read; 328 byte[] chunk = new byte[bufferSize]; 329 while ((read = source.read(chunk)) > 0) 330 destination.write(chunk, 0, read); 331 } 332 333 /** 334 * Creates the form field name for the binary attachment filename by stripping off the file extension - for example, 335 * the filename "test.png" would return "test". 336 * 337 * @param binaryAttachment 338 * The binary attachment for which to create the form field name. 339 * @return The form field name for the given binary attachment. 340 */ 341 /** 342 * returns if the binary attachment stream is closed automatically 343 * 344 * @since 1.7.0 345 * @return {@code true} if the binary stream should be closed automatically, {@code false} otherwise 346 */ 347 public boolean isAutocloseBinaryAttachmentStream() { 348 return autocloseBinaryAttachmentStream; 349 } 350 351 /** 352 * define if the binary attachment stream is closed automatically after sending the content to facebook 353 * 354 * @since 1.7.0 355 * @param autocloseBinaryAttachmentStream 356 * {@code true} if the {@link BinaryAttachment} stream should be closed automatically, {@code false} 357 * otherwise 358 */ 359 public void setAutocloseBinaryAttachmentStream(boolean autocloseBinaryAttachmentStream) { 360 this.autocloseBinaryAttachmentStream = autocloseBinaryAttachmentStream; 361 } 362 363 @Override 364 public Response executeDelete(Request request) throws IOException { 365 return execute(HttpMethod.DELETE, request); 366 } 367 368 private Response execute(HttpMethod httpMethod, Request request) throws IOException { 369 HTTP_LOGGER.debug("Making a {} request to {} with parameters {}", httpMethod.name(), request.getUrl(), 370 request.getParameters()); 371 372 HttpRequest.Builder builder = openConnection(new URL(request.getFullUrl())); 373 builder.timeout(Duration.ofMillis(DEFAULT_READ_TIMEOUT_IN_MS)); 374 375 initHeaderAccessToken(builder, request); 376 customizeRequest(builder, request, httpMethod); 377 378 switch (httpMethod) { 379 case GET: 380 builder.GET(); 381 break; 382 case DELETE: 383 builder.DELETE(); 384 break; 385 default: 386 throw new IllegalArgumentException("Unsupported httpMethod used"); 387 } 388 389 HttpRequest httpRequest = builder.build(); 390 return sendRequest(httpRequest); 391 } 392 393 private Response sendRequest(HttpRequest httpRequest) throws IOException { 394 try { 395 HttpResponse<InputStream> httpResponse = getHttpClient().send(httpRequest, BodyHandlers.ofInputStream()); 396 Map<String, List<String>> headers = Collections.unmodifiableMap(toHeaderMap(httpResponse.headers())); 397 HTTP_LOGGER.trace("Response headers: {}", headers); 398 return createResponse(httpResponse, headers); 399 } catch (InterruptedException e) { 400 Thread.currentThread().interrupt(); 401 throw new IOException("Interrupted while making request", e); 402 } 403 } 404 405 private byte[] readResponseBody(HttpResponse<InputStream> httpResponse) throws IOException { 406 InputStream responseBody = httpResponse.body(); 407 if (responseBody == null) { 408 return new byte[0]; 409 } 410 411 long expectedLength = httpResponse.headers().firstValueAsLong("Content-Length").orElse(-1L); 412 long totalRead = 0; 413 byte[] buffer = new byte[8192]; 414 415 try (InputStream bodyStream = responseBody; ByteArrayOutputStream output = new ByteArrayOutputStream()) { 416 int read; 417 while ((read = bodyStream.read(buffer)) != -1) { 418 output.write(buffer, 0, read); 419 totalRead += read; 420 } 421 422 if (expectedLength >= 0 && expectedLength != totalRead) { 423 throw new IOException("Incomplete response body: expected " + expectedLength + " bytes but read " + totalRead); 424 } 425 426 return output.toByteArray(); 427 } catch (IOException ioe) { 428 throw new IOException("Incomplete response body", ioe); 429 } 430 } 431 432 protected Response createResponse(HttpResponse<InputStream> httpResponse, Map<String, List<String>> headers) 433 throws IOException { 434 byte[] body = readResponseBody(httpResponse); 435 Response response = new Response(httpResponse.statusCode(), StringUtils.toString(body), null, headers); 436 HTTP_LOGGER.debug("Facebook responded with {}", response); 437 return response; 438 } 439 440 private Map<String, List<String>> toHeaderMap(HttpHeaders headers) { 441 Map<String, List<String>> result = new LinkedHashMap<>(); 442 headers.map().forEach((k, v) -> result.put(k, Collections.unmodifiableList(v))); 443 return result; 444 } 445 446}