001package com.thetransactioncompany.jsonrpc2.client;
002
003
004import java.io.IOException;
005import java.io.OutputStreamWriter;
006
007import java.net.CookieManager;
008import java.net.CookiePolicy;
009import java.net.HttpCookie;
010import java.net.HttpURLConnection;
011import java.net.URISyntaxException;
012import java.net.URL;
013import java.net.URLConnection;
014
015import java.security.SecureRandom;
016import java.security.cert.X509Certificate;
017
018import java.util.Collections;
019import java.util.List;
020
021import javax.net.ssl.HttpsURLConnection;
022import javax.net.ssl.SSLContext;
023import javax.net.ssl.SSLSocketFactory;
024import javax.net.ssl.TrustManager;
025import javax.net.ssl.X509TrustManager;
026
027import com.thetransactioncompany.jsonrpc2.JSONRPC2Notification;
028import com.thetransactioncompany.jsonrpc2.JSONRPC2ParseException;
029import com.thetransactioncompany.jsonrpc2.JSONRPC2Request;
030import com.thetransactioncompany.jsonrpc2.JSONRPC2Response;
031
032
033/** 
034 * Sends requests and / or notifications to a specified JSON-RPC 2.0 server 
035 * URL. The JSON-RPC 2.0 messages are dispatched by means of HTTP(S) POST.
036 * This class is thread-safe.
037 *
038 * <p>The client-session class has a number of {@link JSONRPC2SessionOptions 
039 * optional settings}. To change them pass a modified options instance to the
040 * {@link #setOptions setOptions()} method.
041 *
042 * <p>Example JSON-RPC 2.0 client session:
043 *
044 * <pre>
045 * // First, import the required packages:
046 * 
047 * // The Client sessions package
048 * import com.thetransactioncompany.jsonrpc2.client.*;
049 * 
050 * // The Base package for representing JSON-RPC 2.0 messages
051 * import com.thetransactioncompany.jsonrpc2.*;
052 * 
053 * // The JSON Smart package for JSON encoding/decoding (optional)
054 * import net.minidev.json.*;
055 * 
056 * // For creating URLs
057 * import java.net.*;
058 * 
059 * // ...
060 * 
061 * 
062 * // Creating a new session to a JSON-RPC 2.0 web service at a specified URL
063 * 
064 * // The JSON-RPC 2.0 server URL
065 * URL serverURL = null;
066 * 
067 * try {
068 *      serverURL = new URL("http://jsonrpc.example.com:8080");
069 *      
070 * } catch (MalformedURLException e) {
071 *      // handle exception...
072 * }
073 * 
074 * // Create new JSON-RPC 2.0 client session
075 *  JSONRPC2Session mySession = new JSONRPC2Session(serverURL);
076 * 
077 * 
078 * // Once the client session object is created, you can use to send a series
079 * // of JSON-RPC 2.0 requests and notifications to it.
080 * 
081 * // Sending an example "getServerTime" request:
082 * 
083 *  // Construct new request
084 *  String method = "getServerTime";
085 *  int requestID = 0;
086 *  JSONRPC2Request request = new JSONRPC2Request(method, requestID);
087 * 
088 *  // Send request
089 *  JSONRPC2Response response = null;
090 * 
091 *  try {
092 *          response = mySession.send(request);
093 * 
094 *  } catch (JSONRPC2SessionException e) {
095 * 
096 *          System.err.println(e.getMessage());
097 *          // handle exception...
098 *  }
099 * 
100 *  // Print response result / error
101 *  if (response.indicatesSuccess())
102 *      System.out.println(response.getResult());
103 *  else
104 *      System.out.println(response.getError().getMessage());
105 * 
106 * </pre>
107 *
108 * @author Vladimir Dzhuvinov
109 * @author Mike Outland
110 */
111public class JSONRPC2Session {
112
113
114        /** 
115         * The server URL, which protocol must be HTTP or HTTPS. 
116         *
117         * <p>Example URL: "http://jsonrpc.example.com:8080"
118         */
119        private URL url;
120
121
122        /**
123         * The client-session options.
124         */
125        private JSONRPC2SessionOptions options;
126
127
128        /**
129         * Custom HTTP URL connection configurator.
130         */
131        private ConnectionConfigurator connectionConfigurator;
132        
133        
134        /**
135         * Optional HTTP raw response inspector.
136         */
137        private RawResponseInspector responseInspector;
138        
139        
140        /**
141         * Optional HTTP cookie manager. 
142         */
143        private CookieManager cookieManager;
144
145
146        /**
147         * Trust-all-certs (including self-signed) SSL socket factory.
148         */
149        private static final SSLSocketFactory trustAllSocketFactory = createTrustAllSocketFactory();
150
151
152        /**
153         * Creates a new client session to a JSON-RPC 2.0 server at the
154         * specified URL. Uses a default {@link JSONRPC2SessionOptions} 
155         * instance.
156         *
157         * @param url The server URL, e.g. "http://jsonrpc.example.com:8080".
158         *            Must not be {@code null}.
159         */
160        public JSONRPC2Session(final URL url) {
161
162                if (! url.getProtocol().equalsIgnoreCase("http") && 
163                    ! url.getProtocol().equalsIgnoreCase("https")   )
164                        throw new IllegalArgumentException("The URL protocol must be HTTP or HTTPS");
165
166                this.url = url;
167
168                // Default session options
169                options = new JSONRPC2SessionOptions();
170
171                // No initial connection configurator
172                connectionConfigurator = null;
173        }
174        
175        
176        /**
177         * Creates a trust-all-certificates SSL socket factory. Encountered 
178         * exceptions are not rethrown.
179         *
180         * @return The SSL socket factory.
181         */
182        public static SSLSocketFactory createTrustAllSocketFactory() {
183        
184                TrustManager[] trustAllCerts = new TrustManager[] {
185                        
186                        new X509TrustManager() {
187
188                                public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[]{}; }
189
190                                public void checkClientTrusted(X509Certificate[] certs, String authType) { }
191
192                                public void checkServerTrusted(X509Certificate[] certs, String authType) { }
193                        }
194                };
195
196                try {
197                        SSLContext sc = SSLContext.getInstance("SSL");
198                        sc.init(null, trustAllCerts, new SecureRandom());
199                        return sc.getSocketFactory();
200
201                } catch (Exception e) {
202                        
203                        // Ignore
204                        return null;
205                }
206        }
207
208
209        /**
210         * Gets the JSON-RPC 2.0 server URL.
211         *
212         * @return The server URL.
213         */
214        public URL getURL() {
215
216                return url;
217        }
218
219
220        /**
221         * Sets the JSON-RPC 2.0 server URL.
222         *
223         * @param url The server URL. Must not be {@code null}.
224         */
225        public void setURL(final URL url) {
226
227                if (url == null)
228                        throw new IllegalArgumentException("The server URL must not be null");
229                
230                this.url = url;
231        }
232
233
234        /**
235         * Gets the JSON-RPC 2.0 client session options.
236         *
237         * @return The client session options.
238         */
239        public JSONRPC2SessionOptions getOptions() {
240
241                return options;
242        }
243
244
245        /**
246         * Sets the JSON-RPC 2.0 client session options.
247         *
248         * @param options The client session options, must not be {@code null}.
249         */
250        public void setOptions(final JSONRPC2SessionOptions options) {
251
252                if (options == null)
253                        throw new IllegalArgumentException("The client session options must not be null");
254
255                this.options = options;
256        }
257
258
259        /**
260         * Gets the custom HTTP URL connection configurator.
261         *
262         * @since 1.5
263         *
264         * @return The connection configurator, {@code null} if none is set.
265         */
266        public ConnectionConfigurator getConnectionConfigurator() {
267
268                return connectionConfigurator;
269        }
270
271
272        /**
273         * Specifies a custom HTTP URL connection configurator. It will be
274         * {@link ConnectionConfigurator#configure applied} to each new HTTP
275         * connection after the {@link JSONRPC2SessionOptions session options}
276         * are applied and before the connection is established.
277         *
278         * <p>This method may be used to set custom HTTP request headers, 
279         * timeouts or other properties.
280         *
281         * @since 1.5
282         *
283         * @param connectionConfigurator A custom HTTP URL connection 
284         *                               configurator, {@code null} to remove
285         *                               a previously set one.
286         */
287        public void setConnectionConfigurator(final ConnectionConfigurator connectionConfigurator) {
288
289                this.connectionConfigurator = connectionConfigurator;
290        }
291        
292        
293        /**
294         * Gets the optional inspector for the raw HTTP responses.
295         * 
296         * @since 1.6
297         * 
298         * @return The optional inspector for the raw HTTP responses, 
299         *         {@code null} if none is set.
300         */
301        public RawResponseInspector getRawResponseInspector() {
302                
303                return responseInspector;
304        }
305        
306        
307        /**
308         * Specifies an optional inspector for the raw HTTP responses to 
309         * JSON-RPC 2.0 requests and notifications. Its 
310         * {@link RawResponseInspector#inspect inspect} method will be called 
311         * upon reception of a HTTP response.
312         * 
313         * <p>You can use the {@link RawResponseInspector} interface to 
314         * retrieve the unparsed response content and headers.
315         * 
316         * @since 1.6
317         * 
318         * @param responseInspector An optional inspector for the raw HTTP 
319         *                          responses, {@code null} to remove a 
320         *                          previously set one.
321         */
322        public void setRawResponseInspector(final RawResponseInspector responseInspector) {
323                
324                this.responseInspector = responseInspector;
325        }
326        
327        
328        /**
329         * Gets all non-expired HTTP cookies currently stored in the client.
330         * 
331         * @return The HTTP cookies, or empty list if none were set by the 
332         *         server or cookies are not 
333         *         {@link JSONRPC2SessionOptions#acceptCookies accepted}.
334         */
335        public List<HttpCookie> getCookies() {
336                
337                if (cookieManager == null) {
338
339                        return Collections.emptyList();
340                }
341
342                return cookieManager.getCookieStore().getCookies();
343        }       
344
345
346        /**
347         * Applies the required headers to the specified URL connection.
348         *
349         * @param con The URL connection which must be open.
350         */
351        private void applyHeaders(final URLConnection con) {
352
353                // Expect UTF-8 for JSON
354                con.setRequestProperty("Accept-Charset", "UTF-8");
355
356                // Add "Content-Type" header?
357                if (options.getRequestContentType() != null)
358                        con.setRequestProperty("Content-Type", options.getRequestContentType());
359
360                // Add "Origin" header?
361                if (options.getOrigin() != null)
362                        con.setRequestProperty("Origin", options.getOrigin());
363
364                // Add "Accept-Encoding: gzip, deflate" header?
365                if (options.enableCompression())
366                        con.setRequestProperty("Accept-Encoding", "gzip, deflate");
367                
368                // Add "Cookie" headers?
369                if (options.acceptCookies()) {
370
371                        StringBuilder buf = new StringBuilder();
372                        
373                        for (HttpCookie cookie: getCookies()) {
374
375                                if (buf.length() > 0)
376                                        buf.append("; ");
377
378                                buf.append(cookie.toString());
379                        }
380
381                        con.setRequestProperty("Cookie", buf.toString());
382                }
383        }
384        
385        
386        /**
387         * Creates and configures a new URL connection to the JSON-RPC 2.0 
388         * server endpoint according to the session settings.
389         *
390         * @return The URL connection, configured and ready for output (HTTP 
391         *         POST).
392         *
393         * @throws JSONRPC2SessionException If the URL connection couldn't be
394         *                                  created or configured.
395         */
396        private URLConnection createURLConnection()
397                throws JSONRPC2SessionException {
398                
399                // Open HTTP connection
400                URLConnection con;
401
402                try {
403                        // Use proxy?
404                        if (options.getProxy() != null)
405                                con = url.openConnection(options.getProxy());
406                        else
407                                con = url.openConnection();
408
409                } catch (IOException e) {
410
411                        throw new JSONRPC2SessionException(
412                                        "Network exception: " + e.getMessage(),
413                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
414                                        e);
415                }
416                
417                con.setConnectTimeout(options.getConnectTimeout());
418                con.setReadTimeout(options.getReadTimeout());
419
420                applyHeaders(con);
421
422                // Set POST mode
423                con.setDoOutput(true);
424
425                // Set trust all certs SSL factory?
426                if (con instanceof HttpsURLConnection && options.trustsAllCerts()) {
427                
428                        if (trustAllSocketFactory == null) {
429                                closeStreams(con);
430                                throw new JSONRPC2SessionException("Couldn't obtain trust-all SSL socket factory");
431                        }
432                
433                        ((HttpsURLConnection)con).setSSLSocketFactory(trustAllSocketFactory);
434                }
435
436                // Apply connection configurator?
437                if (connectionConfigurator != null)
438                        connectionConfigurator.configure((HttpURLConnection)con);
439                
440                return con;
441        }
442        
443        
444        /**
445         * Posts string data (i.e. JSON string) to the specified URL 
446         * connection.
447         *
448         * @param con  The URL connection. Must be in HTTP POST mode. Must not 
449         *             be {@code null}.
450         * @param data The string data to post. Must not be {@code null}.
451         *
452         * @throws JSONRPC2SessionException If an I/O exception is encountered.
453         */
454        private static void postString(final URLConnection con, final String data)
455                throws JSONRPC2SessionException {
456                
457                try {
458                        OutputStreamWriter wr = new OutputStreamWriter(con.getOutputStream(), "UTF-8");
459                        wr.write(data);
460                        wr.flush();
461                        wr.close();
462
463                } catch (IOException e) {
464
465                        throw new JSONRPC2SessionException(
466                                        "Network exception: " + e.getMessage(),
467                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
468                                        e);
469                }
470        }
471        
472        
473        /**
474         * Reads the raw response from an URL connection (after HTTP POST). 
475         * Invokes the {@link RawResponseInspector} if configured and stores 
476         * any cookies {@link JSONRPC2SessionOptions#acceptCookies()} if
477         * required}.
478         *
479         * @param con The URL connection. It should contain ready data for
480         *            retrieval. Must not be {@code null}.
481         *
482         * @return The raw response.
483         *
484         * @throws JSONRPC2SessionException If an exception is encountered.
485         */
486        private RawResponse readRawResponse(final URLConnection con)
487                throws JSONRPC2SessionException {
488        
489                RawResponse rawResponse;
490                
491                try {
492                        rawResponse = RawResponse.parse((HttpURLConnection)con);
493
494                } catch (IOException e) {
495
496                        throw new JSONRPC2SessionException(
497                                        "Network exception: " + e.getMessage(),
498                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
499                                        e);
500                }
501                
502                if (responseInspector != null)
503                        responseInspector.inspect(rawResponse);
504                
505                if (options.acceptCookies()) {
506
507                        // Init cookie manager?
508                        if (cookieManager == null)
509                                cookieManager = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
510
511                        try {
512                                cookieManager.put(con.getURL().toURI(), rawResponse.getHeaderFields());
513
514                        } catch (URISyntaxException e) {
515
516                                throw new JSONRPC2SessionException(
517                                        "Network exception: " + e.getMessage(),
518                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
519                                        e);
520
521                        } catch (IOException e) {
522
523                                throw new JSONRPC2SessionException(
524                                        "I/O exception: " + e.getMessage(),
525                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
526                                        e);
527                        }
528                }
529                
530                return rawResponse;
531        }
532
533
534        /**
535         * Closes the input, output and error streams of the specified URL
536         * connection. No attempt is made to close the underlying socket with
537         * {@code conn.disconnect} so it may be cached (HTTP 1.1 keep live).
538         * See http://techblog.bozho.net/caveats-of-httpurlconnection/
539         *
540         * @param con The URL connection. May be {@code null}.
541         */
542        private static void closeStreams(final URLConnection con) {
543
544                if (con == null) {
545                        return;
546                }
547
548                try {
549                        if (con.getInputStream() != null) {
550                                con.getInputStream().close();
551                        }
552                } catch (Exception e) {
553                        // ignore
554                }
555
556                try {
557                        if (con.getOutputStream() != null) {
558                                con.getOutputStream().close();
559                        }
560                } catch (Exception e) {
561                        // ignore
562                }
563
564                try {
565                        HttpURLConnection httpCon = (HttpURLConnection)con;
566
567                        if (httpCon.getErrorStream() != null) {
568                                httpCon.getErrorStream().close();
569                        }
570                } catch (Exception e) {
571                        // ignore
572                }
573        }
574
575
576        /** 
577         * Sends a JSON-RPC 2.0 request using HTTP POST and returns the server
578         * response.
579         *
580         * @param request The JSON-RPC 2.0 request to send. Must not be 
581         *                {@code null}.
582         *
583         * @return The JSON-RPC 2.0 response returned by the server.
584         *
585         * @throws JSONRPC2SessionException On a network error, unexpected HTTP 
586         *                                  response content type or invalid 
587         *                                  JSON-RPC 2.0 response.
588         */
589        public JSONRPC2Response send(final JSONRPC2Request request)
590                throws JSONRPC2SessionException {
591
592                // Create and configure URL connection to server endpoint
593                URLConnection con = createURLConnection();
594
595                final RawResponse rawResponse;
596
597                try {
598                        // Send request encoded as JSON
599                        postString(con, request.toString());
600
601                        // Get the response
602                        rawResponse = readRawResponse(con);
603
604                } finally {
605                        closeStreams(con);
606                }
607
608                // Check response content type
609                String contentType = rawResponse.getContentType();
610
611                if (! options.isAllowedResponseContentType(contentType)) {
612
613                        String msg;
614
615                        if (contentType == null)
616                                msg = "Missing Content-Type header in the HTTP response";
617                        else
618                                msg = "Unexpected \"" + contentType + "\" content type of the HTTP response";
619
620                        throw new JSONRPC2SessionException(msg, JSONRPC2SessionException.UNEXPECTED_CONTENT_TYPE);
621                }
622
623                // Parse and return the response
624                JSONRPC2Response response;
625
626                try {
627                        response = JSONRPC2Response.parse(rawResponse.getContent(), 
628                                                          options.preservesParseOrder(), 
629                                                          options.ignoresVersion(),
630                                                          options.parsesNonStdAttributes());
631
632                } catch (JSONRPC2ParseException e) {
633
634                        throw new JSONRPC2SessionException(
635                                        "Invalid JSON-RPC 2.0 response",
636                                        JSONRPC2SessionException.BAD_RESPONSE,
637                                        e);
638                }
639
640                // Response ID must match the request ID, except for
641                // -32700 (parse error), -32600 (invalid request) and 
642                // -32603 (internal error)
643
644                Object reqID = request.getID();
645                Object resID = response.getID();
646
647                if (reqID != null && resID != null && reqID.toString().equals(resID.toString()) ) {
648                        // ok
649                } else if (reqID == null && resID == null) {
650                        // ok
651                } else if (! response.indicatesSuccess() && ( response.getError().getCode() == -32700 ||
652                             response.getError().getCode() == -32600 ||
653                             response.getError().getCode() == -32603    )) {
654                        // ok
655                } else {
656                        throw new JSONRPC2SessionException(
657                                        "Invalid JSON-RPC 2.0 response: ID mismatch: Returned " + 
658                                        resID + ", expected " + reqID,
659                                        JSONRPC2SessionException.BAD_RESPONSE);
660                }
661
662                return response;
663        }
664
665
666        /**
667         * Sends a JSON-RPC 2.0 notification using HTTP POST. Note that 
668         * contrary to requests, notifications produce no server response.
669         *
670         * @param notification The JSON-RPC 2.0 notification to send. Must not
671         *                     be {@code null}.
672         *
673         * @throws JSONRPC2SessionException On a network error.
674         */
675        public void send(final JSONRPC2Notification notification)
676                throws JSONRPC2SessionException {
677
678                // Create and configure URL connection to server endpoint
679                URLConnection con = createURLConnection();
680
681                // Send notification encoded as JSON
682                try {
683                        postString(con, notification.toString());
684
685                } finally {
686
687                        closeStreams(con);
688                }
689        }
690}
691