001package org.consensusj.jsonrpc;
002
003import com.fasterxml.jackson.core.JsonProcessingException;
004import com.fasterxml.jackson.databind.JavaType;
005import com.fasterxml.jackson.databind.ObjectMapper;
006import org.slf4j.Logger;
007import org.slf4j.LoggerFactory;
008
009import javax.net.ssl.HttpsURLConnection;
010import javax.net.ssl.SSLContext;
011import javax.net.ssl.SSLSocketFactory;
012import java.io.IOException;
013import java.io.InputStream;
014import java.io.OutputStream;
015import java.lang.reflect.Type;
016import java.net.HttpURLConnection;
017import java.net.URI;
018import java.nio.charset.StandardCharsets;
019import java.util.concurrent.CompletableFuture;
020
021/**
022 * JSON-RPC Client using {@link HttpURLConnection} formerly named{@code RpcClient}.
023 * <p>
024 * This is a concrete class with generic JSON-RPC functionality, it implements the abstract
025 * method {@link JsonRpcClient#sendRequestForResponseAsync(JsonRpcRequest, Type)} using {@link HttpURLConnection}.
026 * <p>
027 * Uses strongly-typed POJOs representing {@link JsonRpcRequest} and {@link JsonRpcResponse}. The
028 * response object uses a parameterized type for the object that is the actual JSON-RPC `result`.
029 * Using strong types and Jackson to serialize/deserialize to/from strongly-typed POJO's without
030 * using intermediate `Map` or `JsonNode` types.
031 *
032 */
033public class JsonRpcClientHttpUrlConnection implements JsonRpcTransport<JavaType> {
034    private static final Logger log = LoggerFactory.getLogger(JsonRpcClientHttpUrlConnection.class);
035    private final ObjectMapper mapper;
036    private final URI serverURI;
037    private final String username;
038    private final String password;
039    private static final String UTF8 = StandardCharsets.UTF_8.name();
040    private final SSLSocketFactory sslSocketFactory;
041
042    public JsonRpcClientHttpUrlConnection(ObjectMapper mapper, SSLContext sslContext, URI server, final String rpcUser, final String rpcPassword) {
043        this.mapper = mapper;
044        this.sslSocketFactory = sslContext.getSocketFactory();
045        log.debug("Constructing JSON-RPC client for: {}", server);
046        this.serverURI = server;
047        this.username = rpcUser;
048        this.password = rpcPassword;
049    }
050
051    /**
052     * Get the URI of the server this client connects to
053     * @return Server URI
054     */
055    @Override
056    public URI getServerURI() {
057        return serverURI;
058    }
059
060    /**
061     * Send a JSON-RPC request to the server and return a JSON-RPC response.
062     *
063     * @param request JSON-RPC request
064     * @param responseType Response type to deserialize to
065     * @return JSON-RPC response
066     * @throws IOException when thrown by the underlying HttpURLConnection
067     * @throws JsonRpcStatusException when the HTTP response code is other than 200
068     */
069    @Override
070    public <R> JsonRpcResponse<R> sendRequestForResponse(JsonRpcRequest request, JavaType responseType) throws IOException, JsonRpcStatusException {
071        HttpURLConnection connection = openConnection();
072
073        // TODO: Make sure HTTP keep-alive will work
074        // See: http://docs.oracle.com/javase/7/docs/technotes/guides/net/http-keepalive.html
075        // http://developer.android.com/reference/java/net/HttpURLConnection.html
076        // http://android-developers.blogspot.com/2011/09/androids-http-clients.html
077
078        if (log.isDebugEnabled()) {
079            log.debug("JsonRpcRequest: {}", mapper.writeValueAsString(request));
080        }
081        
082        try (OutputStream requestStream = connection.getOutputStream()) {
083            mapper.writeValue(requestStream, request);
084        }
085
086        int responseCode = connection.getResponseCode();
087        log.debug("HTTP Response code: {}", responseCode);
088
089        if (responseCode != 200) {
090            handleBadResponseCode(responseCode, connection);
091        }
092
093        JsonRpcResponse<R> responseJson = responseFromStream(connection.getInputStream(), responseType);
094        connection.disconnect();
095        return responseJson;
096    }
097
098    @Override
099    public <R> CompletableFuture<JsonRpcResponse<R>> sendRequestForResponseAsync(JsonRpcRequest request, JavaType responseType) {
100        return supplyAsync(() -> this.sendRequestForResponse(request, responseType));
101    }
102
103    private <R> JsonRpcResponse<R> responseFromStream(InputStream inputStream, JavaType responseType) throws IOException {
104        JsonRpcResponse<R> responseJson;
105        try {
106            if (log.isDebugEnabled()) {
107                // If logging enabled, copy InputStream to string and log
108                String responseBody = convertStreamToString(inputStream);
109                log.debug("Response Body: {}", responseBody);
110                responseJson = mapper.readValue(responseBody, responseType);
111            } else {
112                // Otherwise convert directly to responseType
113                responseJson = mapper.readValue(inputStream, responseType);
114            }
115        } catch (JsonProcessingException e) {
116            log.error("JsonProcessingException: ", e);
117            // TODO: Map to some kind of JsonRPC exception similar to JsonRPCStatusException
118            throw e;
119        }
120        return responseJson;
121    }
122
123    /**
124     * Prepare and throw JsonRPCStatusException with all relevant info
125     * @param responseCode Non-success response code
126     * @param connection the current connection
127     * @throws IOException IO Error
128     * @throws JsonRpcStatusException An exception containing the HTTP status code and a message
129     */
130    private void handleBadResponseCode(int responseCode, HttpURLConnection connection) throws IOException, JsonRpcStatusException
131    {
132        String responseMessage = connection.getResponseMessage();   // HTTP/1 message like "OK" or "Not found"
133        InputStream errorStream = connection.getErrorStream();
134        if (errorStream != null) {
135            if (connection.getContentType().equals("application/json")) {
136                // We got a JSON error response -- try to parse it as a JsonRpcResponse
137                JsonRpcResponse<Object> bodyJson = responseFromStream(errorStream, responseTypeFor(Object.class));
138                // Since this is an error at the JSON level, let's log it with `debug` level and
139                // let the higher-level software decide whether to log it as `error` or not.
140                log.debug("json error code: {}, message: {}", bodyJson.getError().getCode(), responseMessage);
141                throw new JsonRpcStatusException(responseCode, bodyJson);
142            } else {
143                // No JSON, read response body as string
144                String bodyString = convertStreamToString(errorStream);
145                log.error("error string: {}", bodyString);
146                errorStream.close();
147                throw new JsonRpcStatusException(responseCode, bodyString);
148            }
149        }
150        throw new JsonRpcStatusException(responseMessage, responseCode, responseMessage, 0, null, null);
151    }
152
153    private static String convertStreamToString(java.io.InputStream is) {
154        java.util.Scanner s = new java.util.Scanner(is, UTF8).useDelimiter("\\A");
155        return s.hasNext() ? s.next() : "";
156    }
157
158    private HttpURLConnection openConnection() throws IOException {
159        HttpURLConnection connection =  (HttpURLConnection) serverURI.toURL().openConnection();
160        if (connection instanceof HttpsURLConnection) {
161            ((HttpsURLConnection) connection).setSSLSocketFactory(this.sslSocketFactory);
162        }
163        connection.setDoOutput(true); // For writes
164        connection.setRequestMethod("POST");
165        connection.setRequestProperty("Accept-Charset", UTF8);
166        connection.setRequestProperty("Content-Type", "application/json;charset=" +  UTF8);
167        connection.setRequestProperty("Connection", "close");   // Avoid EOFException: http://stackoverflow.com/questions/19641374/android-eofexception-when-using-httpurlconnection-headers
168
169        String auth = username + ":" + password;
170        String basicAuth = "Basic " + JsonRpcTransport.base64Encode(auth);
171        connection.setRequestProperty ("Authorization", basicAuth);
172
173        return connection;
174    }
175
176    private JavaType responseTypeFor(Class<?> resultType) {
177        return mapper.getTypeFactory().
178                constructParametricType(JsonRpcResponse.class, resultType);
179    }
180}