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.SSLContext;
010import java.lang.reflect.Type;
011import java.net.URI;
012import java.net.http.HttpClient;
013import java.net.http.HttpRequest;
014import java.net.http.HttpResponse;
015import java.nio.charset.StandardCharsets;
016import java.time.Duration;
017import java.util.Optional;
018import java.util.concurrent.CompletableFuture;
019import java.util.concurrent.CompletionException;
020import java.util.function.Function;
021
022/**
023 * Incubating JSON-RPC client using {@link java.net.http.HttpClient}
024 */
025public class JsonRpcClientJavaNet implements JsonRpcTransport<JavaType> {
026    private static final Logger log = LoggerFactory.getLogger(JsonRpcClientJavaNet.class);
027
028    private final ObjectMapper mapper;
029    private final URI serverURI;
030    private final String username;
031    private final String password;
032    private final HttpClient client;
033    private static final String UTF8 = StandardCharsets.UTF_8.name();
034
035
036    public JsonRpcClientJavaNet(ObjectMapper mapper, URI server, final String rpcUser, final String rpcPassword) {
037        this(mapper, JsonRpcTransport.getDefaultSSLContext(), server, rpcUser, rpcPassword);
038    }
039
040    public JsonRpcClientJavaNet(ObjectMapper mapper, SSLContext sslContext, URI server, final String rpcUser, final String rpcPassword) {
041        log.debug("Constructing JSON-RPC client for: {}", server);
042        this.mapper = mapper;
043        this.serverURI = server;
044        this.username = rpcUser;
045        this.password = rpcPassword;
046        this.client = HttpClient.newBuilder()
047                .connectTimeout(Duration.ofMinutes(2))
048                .sslContext(sslContext)
049                .build();
050    }
051
052    /**
053     * {@inheritDoc}
054     */
055    @Override
056    public <R> CompletableFuture<JsonRpcResponse<R>> sendRequestForResponseAsync(JsonRpcRequest request, JavaType responseType) {
057        return sendCommon(request)
058                .thenApply(mappingFuncFor(responseType));
059    }
060
061    // For testing only
062    CompletableFuture<String> sendRequestForResponseString(JsonRpcRequest request) {
063        return sendCommon(request);
064    }
065
066    /**
067     * {@inheritDoc}
068     */
069    @Override
070    public URI getServerURI() {
071        return serverURI;
072    }
073
074    private String encodeJsonRpcRequest(JsonRpcRequest request) throws JsonProcessingException {
075        return mapper.writeValueAsString(request);
076    }
077
078    /**
079     * @param request A JSON-RPC request
080     * @return A future for a JSON-RPC response in String format
081     */
082    private CompletableFuture<String> sendCommon(JsonRpcRequest request) {
083        log.debug("Send: {}", request);
084        try {
085            HttpRequest httpRequest = buildJsonRpcPostRequest(request);
086            return client.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString())
087                    .whenComplete(this::log)
088                    .thenCompose(this::handleStatusError)
089                    .thenApply(HttpResponse::body)
090                    .whenComplete(this::log);
091        } catch (JsonProcessingException e) {
092            return CompletableFuture.failedFuture(e);
093        }
094    }
095
096    /**
097     * Process the HTTP status code. Treats values other than 200 as an error.
098     * @param response A received HTTP response.
099     * @return Completed or failed future as appropriate.
100     */
101    private CompletableFuture<HttpResponse<String>> handleStatusError(HttpResponse<String> response) {
102        if (response.statusCode() != 200) {
103            String body = response.body();
104            log.warn("Bad status code: {}: {}", response.statusCode(), body);
105            log.debug("Headers: {}", response.headers());
106            // Return a failed future containing a JsonRpcStatusException. Create the exception
107            // from a JsonRpcResponse if one can be built, otherwise just use the body string.
108            return CompletableFuture.failedFuture(response.headers()
109                    .firstValue("Content-Type")
110                    .map(s -> s.contains("application/json"))
111                    .flatMap(b -> readErrorResponse(body))
112                    .map(r -> new JsonRpcStatusException(response.statusCode(), r))
113                    .orElse(new JsonRpcStatusException(response.statusCode(), body))
114            );
115        } else {
116            return CompletableFuture.completedFuture(response);
117        }
118    }
119
120    // Try to read a JsonRpcResponse from a string (error case)
121    private Optional<JsonRpcResponse<Object>> readErrorResponse(String body) {
122        JsonRpcResponse<Object> response;
123        try {
124            response = mapper.readValue(body, responseTypeFor(Object.class));
125        } catch (JsonProcessingException e) {
126            response = null;
127        }
128        return Optional.ofNullable(response);
129    }
130
131    private HttpRequest buildJsonRpcPostRequest(JsonRpcRequest request) throws JsonProcessingException {
132        String requestString = encodeJsonRpcRequest(request);
133        return buildJsonRpcPostRequest(requestString);
134    }
135
136    private HttpRequest buildJsonRpcPostRequest(String requestString) {
137        log.info("request is: {}", requestString);
138
139        String auth = username + ":" + password;
140        String basicAuth = "Basic " + JsonRpcTransport.base64Encode(auth);
141
142        return HttpRequest
143                .newBuilder(serverURI)
144                .header("Content-Type", "application/json;charset=" +  UTF8)
145                .header("Accept-Charset", UTF8)
146                .header("Accept", "application/json")
147                .header("Authorization", basicAuth)
148                .POST(HttpRequest.BodyPublishers.ofString(requestString))
149                .build();
150    }
151
152    // return a MappingFunction for a given type
153    private <R, T extends Type> MappingFunction<R> mappingFuncFor(T responseType) {
154        return s -> mapper.readValue(s, (JavaType) responseType);
155    }
156
157    /**
158     * Map a response string to a Java object. Wraps checked {@link JsonProcessingException}
159     * in unchecked {@link CompletionException}.
160     * @param <R> result type
161     */
162    @FunctionalInterface
163    protected interface MappingFunction<R> extends Function<String, R> {
164
165        /**
166         * Gets a result. Wraps checked {@link JsonProcessingException} in {@link CompletionException}
167         * @param s input
168         * @return a result
169         * @throws CompletionException (unchecked) if a JsonProcessingException exception occurs
170         */
171        @Override
172        default R apply(String s) throws CompletionException {
173            try {
174                return applyThrows(s);
175            } catch (Exception e) {
176                throw new CompletionException(e);
177            }
178        }
179
180        /**
181         * Gets a result and may throw a checked exception.
182         * @param s input
183         * @return a result
184         * @throws JsonProcessingException Checked Exception
185         */
186        R applyThrows(String s) throws Exception;
187    }
188
189    /**
190     * Logging action for a {@code CompletionStage} that returns {@link HttpResponse}
191     * <p>
192     * Note that an error at this layer should be treated as a warning for logging purposes, because network
193     * errors are relatively common and should be handled and/or logged at higher layers of the stack.
194     * @param httpResponse non-null on success
195     * @param t non-null on error
196     */
197    private void log(HttpResponse<String> httpResponse, Throwable t) {
198        if ((httpResponse != null)) {
199            log.info("log data string: {}", httpResponse);
200        } else {
201            log.warn("exception: ", t);
202        }
203    }
204
205    /**
206     * Logging action for a {@code CompletionStage} that returns {@link String}. In the current
207     * implementation of this client, that should be a JSON-formatted string.
208     * <p>
209     * Note that an error at this layer should be treated as a warning for logging purposes, because network
210     * errors are relatively common and should be handled and/or logged at higher layers of the stack.
211     * @param s non-null on success
212     * @param t non-null on error
213     */
214    private void log(String s, Throwable t) {
215        if ((s != null)) {
216            log.info("log data string: {}", s.substring(0 ,Math.min(100, s.length())));
217        } else {
218            log.warn("exception: ", t);
219        }
220    }
221
222    private JavaType responseTypeFor(Class<?> resultType) {
223        return mapper.getTypeFactory().
224                constructParametricType(JsonRpcResponse.class, resultType);
225    }
226}