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