001package com.nimbusds.oauth2.sdk.http; 002 003 004import java.io.*; 005import java.net.*; 006import java.util.Enumeration; 007import java.util.Map; 008 009import javax.servlet.http.HttpServletRequest; 010 011import net.jcip.annotations.ThreadSafe; 012 013import net.minidev.json.JSONObject; 014 015import com.nimbusds.oauth2.sdk.ParseException; 016import com.nimbusds.oauth2.sdk.util.JSONObjectUtils; 017import com.nimbusds.oauth2.sdk.util.URLUtils; 018 019 020/** 021 * HTTP request with support for the parameters required to construct an 022 * {@link com.nimbusds.oauth2.sdk.Request OAuth 2.0 request message}. 023 * 024 * <p>Supported HTTP methods: 025 * 026 * <ul> 027 * <li>{@link Method#GET HTTP GET} 028 * <li>{@link Method#POST HTTP POST} 029 * <li>{@link Method#POST HTTP PUT} 030 * <li>{@link Method#POST HTTP DELETE} 031 * </ul> 032 * 033 * <p>Supported request headers: 034 * 035 * <ul> 036 * <li>Content-Type 037 * <li>Authorization 038 * <li>Accept 039 * <li>Etc. 040 * </ul> 041 * 042 * <p>Supported timeouts: 043 * 044 * <ul> 045 * <li>On HTTP connect 046 * <li>On HTTP response read 047 * </ul> 048 */ 049@ThreadSafe 050public class HTTPRequest extends HTTPMessage { 051 052 053 /** 054 * Enumeration of the HTTP methods used in OAuth 2.0 requests. 055 */ 056 public static enum Method { 057 058 /** 059 * HTTP GET. 060 */ 061 GET, 062 063 064 /** 065 * HTTP POST. 066 */ 067 POST, 068 069 070 /** 071 * HTTP PUT. 072 */ 073 PUT, 074 075 076 /** 077 * HTTP DELETE. 078 */ 079 DELETE 080 } 081 082 083 /** 084 * The request method. 085 */ 086 private final Method method; 087 088 089 /** 090 * The request URL. 091 */ 092 private final URL url; 093 094 095 /** 096 * The query string / post body. 097 */ 098 private String query = null; 099 100 101 /** 102 * The HTTP connect timeout, in milliseconds. Zero implies none. 103 */ 104 private int connectTimeout = 0; 105 106 107 /** 108 * The HTTP response read timeout, in milliseconds. Zero implies none. 109 110 */ 111 private int readTimeout = 0; 112 113 114 /** 115 * Creates a new minimally specified HTTP request. 116 * 117 * @param method The HTTP request method. Must not be {@code null}. 118 * @param url The HTTP request URL. Must not be {@code null}. 119 */ 120 public HTTPRequest(final Method method, final URL url) { 121 122 if (method == null) 123 throw new IllegalArgumentException("The HTTP method must not be null"); 124 125 this.method = method; 126 127 128 if (url == null) 129 throw new IllegalArgumentException("The HTTP URL must not be null"); 130 131 this.url = url; 132 } 133 134 135 /** 136 * Reconstructs the request URL string for the specified servlet 137 * request. The host part is always the local IP address. The query 138 * string and fragment is always omitted. 139 * 140 * @param request The servlet request. Must not be {@code null}. 141 * 142 * @return The reconstructed request URL string. 143 */ 144 private static String reconstructRequestURLString(final HttpServletRequest request) { 145 146 StringBuilder sb = new StringBuilder("http"); 147 148 if (request.isSecure()) 149 sb.append('s'); 150 151 sb.append("://"); 152 153 String localAddress = request.getLocalAddr(); 154 155 if (localAddress.contains(".")) { 156 // IPv3 address 157 sb.append(localAddress); 158 } else if (localAddress.contains(":")) { 159 // IPv6 address, see RFC 2732 160 sb.append('['); 161 sb.append(localAddress); 162 sb.append(']'); 163 } else { 164 // Don't know what to do 165 } 166 167 if (! request.isSecure() && request.getLocalPort() != 80) { 168 // HTTP plain at port other than 80 169 sb.append(':'); 170 sb.append(request.getLocalPort()); 171 } 172 173 if (request.isSecure() && request.getLocalPort() != 443) { 174 // HTTPS at port other than 443 (default TLS) 175 sb.append(':'); 176 sb.append(request.getLocalPort()); 177 } 178 179 String path = request.getRequestURI(); 180 181 if (path != null) 182 sb.append(path); 183 184 return sb.toString(); 185 } 186 187 188 /** 189 * Creates a new HTTP request from the specified HTTP servlet request. 190 * 191 * @param sr The servlet request. Must not be {@code null}. 192 * 193 * @throws IllegalArgumentException The the servlet request method is 194 * not GET, POST, PUT or DELETE or the 195 * content type header value couldn't 196 * be parsed. 197 * @throws IOException For a POST or PUT body that 198 * couldn't be read due to an I/O 199 * exception. 200 */ 201 public HTTPRequest(final HttpServletRequest sr) 202 throws IOException { 203 204 this(sr, -1); 205 } 206 207 /** 208 * Creates a new HTTP request from the specified HTTP servlet request. 209 * 210 * @param sr The servlet request. Must not be 211 * {@code null}. 212 * @param maxEntityLength The maximum entity length to accept, -1 for 213 * no limit. 214 * 215 * @throws IllegalArgumentException The the servlet request method is 216 * not GET, POST, PUT or DELETE or the 217 * content type header value couldn't 218 * be parsed. 219 * @throws IOException For a POST or PUT body that 220 * couldn't be read due to an I/O 221 * exception. 222 */ 223 public HTTPRequest(final HttpServletRequest sr, 224 final long maxEntityLength) 225 throws IOException { 226 227 method = HTTPRequest.Method.valueOf(sr.getMethod().toUpperCase()); 228 229 String urlString = reconstructRequestURLString(sr); 230 231 try { 232 url = new URL(urlString); 233 234 } catch (MalformedURLException e) { 235 236 throw new IllegalArgumentException("Invalid request URL: " + e.getMessage() + ": " + urlString, e); 237 } 238 239 try { 240 setContentType(sr.getContentType()); 241 242 } catch (ParseException e) { 243 244 throw new IllegalArgumentException("Invalid Content-Type header value: " + e.getMessage(), e); 245 } 246 247 Enumeration<String> headerNames = sr.getHeaderNames(); 248 249 while (headerNames.hasMoreElements()) { 250 final String headerName = headerNames.nextElement(); 251 setHeader(headerName, sr.getHeader(headerName)); 252 } 253 254 if (method.equals(Method.GET) || method.equals(Method.DELETE)) { 255 256 setQuery(sr.getQueryString()); 257 258 } else if (method.equals(Method.POST) || method.equals(Method.PUT)) { 259 260 // read body 261 StringBuilder body = new StringBuilder(256); 262 263 BufferedReader reader = sr.getReader(); 264 265 char[] cbuf = new char[256]; 266 267 int readChars; 268 269 while ((readChars = reader.read(cbuf)) != -1) { 270 271 body.append(cbuf, 0, readChars); 272 273 if (maxEntityLength > 0 && body.length() > maxEntityLength) { 274 throw new IOException("Request entity body is too large, limit is " + maxEntityLength + " chars"); 275 } 276 } 277 278 reader.close(); 279 280 setQuery(body.toString()); 281 } 282 } 283 284 285 /** 286 * Gets the request method. 287 * 288 * @return The request method. 289 */ 290 public Method getMethod() { 291 292 return method; 293 } 294 295 296 /** 297 * Gets the request URL. 298 * 299 * @return The request URL. 300 */ 301 public URL getURL() { 302 303 return url; 304 } 305 306 307 /** 308 * Ensures this HTTP request has the specified method. 309 * 310 * @param expectedMethod The expected method. Must not be {@code null}. 311 * 312 * @throws ParseException If the method doesn't match the expected. 313 */ 314 public void ensureMethod(final Method expectedMethod) 315 throws ParseException { 316 317 if (method != expectedMethod) 318 throw new ParseException("The HTTP request method must be " + expectedMethod); 319 } 320 321 322 /** 323 * Gets the {@code Authorization} header value. 324 * 325 * @return The {@code Authorization} header value, {@code null} if not 326 * specified. 327 */ 328 public String getAuthorization() { 329 330 return getHeader("Authorization"); 331 } 332 333 334 /** 335 * Sets the {@code Authorization} header value. 336 * 337 * @param authz The {@code Authorization} header value, {@code null} if 338 * not specified. 339 */ 340 public void setAuthorization(final String authz) { 341 342 setHeader("Authorization", authz); 343 } 344 345 346 /** 347 * Gets the {@code Accept} header value. 348 * 349 * @return The {@code Accept} header value, {@code null} if not 350 * specified. 351 */ 352 public String getAccept() { 353 354 return getHeader("Accept"); 355 } 356 357 358 /** 359 * Sets the {@code Accept} header value. 360 * 361 * @param accept The {@code Accept} header value, {@code null} if not 362 * specified. 363 */ 364 public void setAccept(final String accept) { 365 366 setHeader("Accept", accept); 367 } 368 369 370 /** 371 * Gets the raw (undecoded) query string if the request is HTTP GET or 372 * the entity body if the request is HTTP POST. 373 * 374 * <p>Note that the '?' character preceding the query string in GET 375 * requests is not included in the returned string. 376 * 377 * <p>Example query string (line breaks for clarity): 378 * 379 * <pre> 380 * response_type=code 381 * &client_id=s6BhdRkqt3 382 * &state=xyz 383 * &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb 384 * </pre> 385 * 386 * @return For HTTP GET requests the URL query string, for HTTP POST 387 * requests the body. {@code null} if not specified. 388 */ 389 public String getQuery() { 390 391 return query; 392 } 393 394 395 /** 396 * Sets the raw (undecoded) query string if the request is HTTP GET or 397 * the entity body if the request is HTTP POST. 398 * 399 * <p>Note that the '?' character preceding the query string in GET 400 * requests must not be included. 401 * 402 * <p>Example query string (line breaks for clarity): 403 * 404 * <pre> 405 * response_type=code 406 * &client_id=s6BhdRkqt3 407 * &state=xyz 408 * &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb 409 * </pre> 410 * 411 * @param query For HTTP GET requests the URL query string, for HTTP 412 * POST requests the body. {@code null} if not specified. 413 */ 414 public void setQuery(final String query) { 415 416 this.query = query; 417 } 418 419 420 /** 421 * Ensures this HTTP response has a specified query string or entity 422 * body. 423 * 424 * @throws ParseException If the query string or entity body is missing 425 * or empty. 426 */ 427 private void ensureQuery() 428 throws ParseException { 429 430 if (query == null || query.trim().isEmpty()) 431 throw new ParseException("Missing or empty HTTP query string / entity body"); 432 } 433 434 435 /** 436 * Gets the request query as a parameter map. The parameters are 437 * decoded according to {@code application/x-www-form-urlencoded}. 438 * 439 * @return The request query parameters, decoded. If none the map will 440 * be empty. 441 */ 442 public Map<String,String> getQueryParameters() { 443 444 return URLUtils.parseParameters(query); 445 } 446 447 448 /** 449 * Gets the request query or entity body as a JSON Object. 450 * 451 * @return The request query or entity body as a JSON object. 452 * 453 * @throws ParseException If the Content-Type header isn't 454 * {@code application/json}, the request query 455 * or entity body is {@code null}, empty or 456 * couldn't be parsed to a valid JSON object. 457 */ 458 public JSONObject getQueryAsJSONObject() 459 throws ParseException { 460 461 ensureContentType(CommonContentTypes.APPLICATION_JSON); 462 463 ensureQuery(); 464 465 return JSONObjectUtils.parseJSONObject(query); 466 } 467 468 469 /** 470 * Gets the HTTP connect timeout. 471 * 472 * @return The HTTP connect read timeout, in milliseconds. Zero implies 473 * no timeout. 474 */ 475 public int getConnectTimeout() { 476 477 return connectTimeout; 478 } 479 480 481 /** 482 * Sets the HTTP connect timeout. 483 * 484 * @param connectTimeout The HTTP connect timeout, in milliseconds. 485 * Zero implies no timeout. Must not be negative. 486 */ 487 public void setConnectTimeout(final int connectTimeout) { 488 489 if (connectTimeout < 0) { 490 throw new IllegalArgumentException("The HTTP connect timeout must be zero or positive"); 491 } 492 493 this.connectTimeout = connectTimeout; 494 } 495 496 497 /** 498 * Gets the HTTP response read timeout. 499 * 500 * @return The HTTP response read timeout, in milliseconds. Zero 501 * implies no timeout. 502 */ 503 public int getReadTimeout() { 504 505 return readTimeout; 506 } 507 508 509 /** 510 * Sets the HTTP response read timeout. 511 * 512 * @param readTimeout The HTTP response read timeout, in milliseconds. 513 * Zero implies no timeout. Must not be negative. 514 */ 515 public void setReadTimeout(final int readTimeout) { 516 517 if (readTimeout < 0) { 518 throw new IllegalArgumentException("The HTTP response read timeout must be zero or positive"); 519 } 520 521 this.readTimeout = readTimeout; 522 } 523 524 525 /** 526 * Returns an established HTTP URL connection for this HTTP request. 527 * 528 * @return The HTTP URL connection, with the request sent and ready to 529 * read the response. 530 * 531 * @throws IOException If the HTTP request couldn't be made, due to a 532 * network or other error. 533 */ 534 public HttpURLConnection toHttpURLConnection() 535 throws IOException { 536 537 URL finalURL = url; 538 539 if (query != null && (method.equals(HTTPRequest.Method.GET) || method.equals(Method.DELETE))) { 540 541 // Append query string 542 StringBuilder sb = new StringBuilder(url.toString()); 543 sb.append('?'); 544 sb.append(query); 545 546 try { 547 finalURL = new URL(sb.toString()); 548 549 } catch (MalformedURLException e) { 550 551 throw new IOException("Couldn't append query string: " + e.getMessage(), e); 552 } 553 } 554 555 HttpURLConnection conn = (HttpURLConnection)finalURL.openConnection(); 556 557 for (Map.Entry<String,String> header: getHeaders().entrySet()) { 558 conn.setRequestProperty(header.getKey(), header.getValue()); 559 } 560 561 conn.setRequestMethod(method.name()); 562 conn.setConnectTimeout(connectTimeout); 563 conn.setReadTimeout(readTimeout); 564 565 if (method.equals(HTTPRequest.Method.POST) || method.equals(Method.PUT)) { 566 567 conn.setDoOutput(true); 568 569 if (getContentType() != null) 570 conn.setRequestProperty("Content-Type", getContentType().toString()); 571 572 if (query != null) { 573 OutputStreamWriter writer = new OutputStreamWriter(conn.getOutputStream()); 574 writer.write(query); 575 writer.close(); 576 } 577 } 578 579 return conn; 580 } 581 582 583 /** 584 * Sends this HTTP request to the request URL and retrieves the 585 * resulting HTTP response. 586 * 587 * @return The resulting HTTP response. 588 * 589 * @throws IOException If the HTTP request couldn't be made, due to a 590 * network or other error. 591 */ 592 public HTTPResponse send() 593 throws IOException { 594 595 HttpURLConnection conn = toHttpURLConnection(); 596 597 int statusCode; 598 599 BufferedReader reader; 600 601 try { 602 // Open a connection, then send method and headers 603 reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); 604 605 // The next step is to get the status 606 statusCode = conn.getResponseCode(); 607 608 } catch (IOException e) { 609 610 // HttpUrlConnection will throw an IOException if any 611 // 4XX response is sent. If we request the status 612 // again, this time the internal status will be 613 // properly set, and we'll be able to retrieve it. 614 statusCode = conn.getResponseCode(); 615 616 if (statusCode == -1) { 617 // Rethrow IO exception 618 throw e; 619 } else { 620 // HTTP status code indicates the response got 621 // through, read the content but using error stream 622 InputStream errStream = conn.getErrorStream(); 623 624 if (errStream != null) { 625 // We have useful HTTP error body 626 reader = new BufferedReader(new InputStreamReader(errStream)); 627 } else { 628 // No content, set to empty string 629 reader = new BufferedReader(new StringReader("")); 630 } 631 } 632 } 633 634 StringBuilder body = new StringBuilder(); 635 636 try { 637 String line; 638 639 while ((line = reader.readLine()) != null) { 640 body.append(line); 641 body.append(System.getProperty("line.separator")); 642 } 643 644 reader.close(); 645 646 } finally { 647 conn.disconnect(); 648 } 649 650 651 HTTPResponse response = new HTTPResponse(statusCode); 652 653 String location = conn.getHeaderField("Location"); 654 655 if (location != null) { 656 657 try { 658 response.setLocation(new URI(location)); 659 660 } catch (URISyntaxException e) { 661 throw new IOException("Couldn't parse Location header: " + e.getMessage(), e); 662 } 663 } 664 665 666 try { 667 response.setContentType(conn.getContentType()); 668 669 } catch (ParseException e) { 670 671 throw new IOException("Couldn't parse Content-Type header: " + e.getMessage(), e); 672 } 673 674 675 response.setCacheControl(conn.getHeaderField("Cache-Control")); 676 677 response.setPragma(conn.getHeaderField("Pragma")); 678 679 response.setWWWAuthenticate(conn.getHeaderField("WWW-Authenticate")); 680 681 String bodyContent = body.toString(); 682 683 if (! bodyContent.isEmpty()) 684 response.setContent(bodyContent); 685 686 687 return response; 688 } 689}