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         * &amp;client_id=s6BhdRkqt3
382         * &amp;state=xyz
383         * &amp;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         * &amp;client_id=s6BhdRkqt3
407         * &amp;state=xyz
408         * &amp;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.parse(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}