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