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 */
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                                throw new JSONRPC2SessionException("Couldn't obtain trust-all SSL socket factory");
430                
431                        ((HttpsURLConnection)con).setSSLSocketFactory(trustAllSocketFactory);
432                }
433
434                // Apply connection configurator?
435                if (connectionConfigurator != null)
436                        connectionConfigurator.configure((HttpURLConnection)con);
437                
438                return con;
439        }
440        
441        
442        /**
443         * Posts string data (i.e. JSON string) to the specified URL 
444         * connection.
445         *
446         * @param con  The URL connection. Must be in HTTP POST mode. Must not 
447         *             be {@code null}.
448         * @param data The string data to post. Must not be {@code null}.
449         *
450         * @throws JSONRPC2SessionException If an I/O exception is encountered.
451         */
452        private static void postString(final URLConnection con, final String data)
453                throws JSONRPC2SessionException {
454                
455                try {
456                        OutputStreamWriter wr = new OutputStreamWriter(con.getOutputStream(), "UTF-8");
457                        wr.write(data);
458                        wr.flush();
459                        wr.close();
460
461                } catch (IOException e) {
462
463                        throw new JSONRPC2SessionException(
464                                        "Network exception: " + e.getMessage(),
465                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
466                                        e);
467                }
468        }
469        
470        
471        /**
472         * Reads the raw response from an URL connection (after HTTP POST). 
473         * Invokes the {@link RawResponseInspector} if configured and stores 
474         * any cookies {@link JSONRPC2SessionOptions#acceptCookies()} if
475         * required}.
476         *
477         * @param con The URL connection. It should contain ready data for
478         *            retrieval. Must not be {@code null}.
479         *
480         * @return The raw response.
481         *
482         * @throws JSONRPC2SessionException If an exception is encountered.
483         */
484        private RawResponse readRawResponse(final URLConnection con)
485                throws JSONRPC2SessionException {
486        
487                RawResponse rawResponse;
488                
489                try {
490                        rawResponse = RawResponse.parse((HttpURLConnection)con);
491
492                } catch (IOException e) {
493
494                        throw new JSONRPC2SessionException(
495                                        "Network exception: " + e.getMessage(),
496                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
497                                        e);
498                }
499                
500                if (responseInspector != null)
501                        responseInspector.inspect(rawResponse);
502                
503                if (options.acceptCookies()) {
504
505                        // Init cookie manager?
506                        if (cookieManager == null)
507                                cookieManager = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
508
509                        try {
510                                cookieManager.put(con.getURL().toURI(), rawResponse.getHeaderFields());
511
512                        } catch (URISyntaxException e) {
513
514                                throw new JSONRPC2SessionException(
515                                        "Network exception: " + e.getMessage(),
516                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
517                                        e);
518
519                        } catch (IOException e) {
520
521                                throw new JSONRPC2SessionException(
522                                        "I/O exception: " + e.getMessage(),
523                                        JSONRPC2SessionException.NETWORK_EXCEPTION,
524                                        e);
525                        }
526                }
527                
528                return rawResponse;
529        }
530
531
532        /** 
533         * Sends a JSON-RPC 2.0 request using HTTP POST and returns the server
534         * response.
535         *
536         * @param request The JSON-RPC 2.0 request to send. Must not be 
537         *                {@code null}.
538         *
539         * @return The JSON-RPC 2.0 response returned by the server.
540         *
541         * @throws JSONRPC2SessionException On a network error, unexpected HTTP 
542         *                                  response content type or invalid 
543         *                                  JSON-RPC 2.0 response.
544         */
545        public JSONRPC2Response send(final JSONRPC2Request request)
546                throws JSONRPC2SessionException {
547
548                // Create and configure URL connection to server endpoint
549                URLConnection con = createURLConnection();
550
551                // Send request encoded as JSON
552                postString(con, request.toString());
553
554                // Get the response
555                RawResponse rawResponse = readRawResponse(con);
556
557                // Check response content type
558                String contentType = rawResponse.getContentType();
559
560                if (! options.isAllowedResponseContentType(contentType)) {
561
562                        String msg;
563
564                        if (contentType == null)
565                                msg = "Missing Content-Type header in the HTTP response";
566                        else
567                                msg = "Unexpected \"" + contentType + "\" content type of the HTTP response";
568
569                        throw new JSONRPC2SessionException(msg, JSONRPC2SessionException.UNEXPECTED_CONTENT_TYPE);
570                }
571
572                // Parse and return the response
573                JSONRPC2Response response;
574
575                try {
576                        response = JSONRPC2Response.parse(rawResponse.getContent(), 
577                                                          options.preservesParseOrder(), 
578                                                          options.ignoresVersion(),
579                                                          options.parsesNonStdAttributes());
580
581                } catch (JSONRPC2ParseException e) {
582
583                        throw new JSONRPC2SessionException(
584                                        "Invalid JSON-RPC 2.0 response",
585                                        JSONRPC2SessionException.BAD_RESPONSE,
586                                        e);
587                }
588
589                // Response ID must match the request ID, except for
590                // -32700 (parse error), -32600 (invalid request) and 
591                // -32603 (internal error)
592
593                Object reqID = request.getID();
594                Object resID = response.getID();
595
596                if (reqID != null && resID != null && reqID.toString().equals(resID.toString()) ) {
597                        // ok
598                } else if (reqID == null && resID == null) {
599                        // ok
600                } else if (! response.indicatesSuccess() && ( response.getError().getCode() == -32700 ||
601                             response.getError().getCode() == -32600 ||
602                             response.getError().getCode() == -32603    )) {
603                        // ok
604                } else {
605                        throw new JSONRPC2SessionException(
606                                        "Invalid JSON-RPC 2.0 response: ID mismatch: Returned " + 
607                                        resID + ", expected " + reqID,
608                                        JSONRPC2SessionException.BAD_RESPONSE);
609                }
610
611                return response;
612        }
613
614
615        /**
616         * Sends a JSON-RPC 2.0 notification using HTTP POST. Note that 
617         * contrary to requests, notifications produce no server response.
618         *
619         * @param notification The JSON-RPC 2.0 notification to send. Must not
620         *                     be {@code null}.
621         *
622         * @throws JSONRPC2SessionException On a network error.
623         */
624        public void send(final JSONRPC2Notification notification)
625                throws JSONRPC2SessionException {
626
627                // Create and configure URL connection to server endpoint
628                URLConnection con = createURLConnection();
629
630                // Send notification encoded as JSON
631                postString(con, notification.toString());
632        }
633}
634