package com.github.msemys.esjc.http; import com.github.msemys.esjc.UserCredentials; import com.github.msemys.esjc.http.handler.HttpResponseHandler; import com.github.msemys.esjc.util.concurrent.ResettableLatch; import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBuf; import io.netty.channel.*; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.http.*; import io.netty.handler.logging.LogLevel; import io.netty.handler.logging.LoggingHandler; import io.netty.util.concurrent.DefaultThreadFactory; import io.netty.util.concurrent.Future; import java.net.InetSocketAddress; import java.time.Duration; import java.util.Base64; import java.util.Queue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicBoolean; import static com.github.msemys.esjc.util.Numbers.isPositive; import static com.github.msemys.esjc.util.Preconditions.*; import static com.github.msemys.esjc.util.Strings.*; import static io.netty.buffer.Unpooled.copiedBuffer; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.concurrent.Executors.newSingleThreadExecutor; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; /** * HTTP client without pipelining support */ public class HttpClient implements AutoCloseable { private final EventLoopGroup group = new NioEventLoopGroup(0, new DefaultThreadFactory("es-http")); private final Bootstrap bootstrap; private final String host; private final boolean acceptGzip; private final long operationTimeoutMillis; private final ExecutorService queueExecutor = newSingleThreadExecutor(new DefaultThreadFactory("es-http-queue")); private final Queue<HttpOperation> queue = new ConcurrentLinkedQueue<>(); private final AtomicBoolean isProcessing = new AtomicBoolean(); private final ResettableLatch received = new ResettableLatch(false); private volatile Channel channel; private HttpClient(Builder builder) { host = builder.address.getHostString(); acceptGzip = builder.acceptGzip; operationTimeoutMillis = builder.operationTimeout.toMillis(); bootstrap = new Bootstrap() .remoteAddress(builder.address) .option(ChannelOption.TCP_NODELAY, true) .option(ChannelOption.SO_REUSEADDR, false) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) builder.connectTimeout.toMillis()) .group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast("http-codec", new HttpClientCodec()); if (acceptGzip) { pipeline.addLast("content-decompressor", new HttpContentDecompressor()); } pipeline.addLast("object-aggregator", new HttpObjectAggregator(builder.maxContentLength)); pipeline.addLast("logger", new LoggingHandler(HttpClient.class, LogLevel.TRACE)); pipeline.addLast("response-handler", new HttpResponseHandler()); } }); } public CompletableFuture<FullHttpResponse> send(HttpRequest request) { checkNotNull(request, "request is null"); checkState(isRunning(), "HTTP client is closed"); request.headers().set(HttpHeaderNames.HOST, host); if (acceptGzip) { request.headers().set(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.GZIP); } CompletableFuture<FullHttpResponse> response = new CompletableFuture<>(); enqueue(new HttpOperation(request, response)); return response; } private void enqueue(HttpOperation operation) { checkNotNull(operation, "operation is null"); queue.offer(operation); if (isProcessing.compareAndSet(false, true)) { queueExecutor.execute(this::processQueue); } } private void processQueue() { do { HttpOperation operation; while ((operation = queue.poll()) != null) { if (channel == null || !channel.isActive()) { try { channel = bootstrap.connect().syncUninterruptibly().channel(); } catch (Exception e) { operation.response.completeExceptionally(e); if (isRunning()) { continue; } else { break; } } } write(operation); } isProcessing.set(false); } while (isRunning() && !queue.isEmpty() && isProcessing.compareAndSet(false, true)); } private void write(HttpOperation operation) { received.reset(); operation.response.whenComplete((r, t) -> received.release()); HttpResponseHandler responseHandler = channel.pipeline().get(HttpResponseHandler.class); responseHandler.pendingResponse = operation.response; try { channel.writeAndFlush(operation.request).sync(); if (!received.await(operationTimeoutMillis, MILLISECONDS)) { channel.close().awaitUninterruptibly(); operation.response.completeExceptionally(new HttpOperationTimeoutException(operation.request)); } } catch (Exception e) { operation.response.completeExceptionally(e); } finally { responseHandler.pendingResponse = null; } } private boolean isRunning() { return !group.isShuttingDown(); } @Override public void close() { Future shutdownFuture = group.shutdownGracefully(0, 15, SECONDS); queueExecutor.shutdown(); HttpOperation operation; while ((operation = queue.poll()) != null) { operation.response.completeExceptionally(new HttpClientException("Client closed")); } shutdownFuture.awaitUninterruptibly(); try { queueExecutor.awaitTermination(5, SECONDS); } catch (InterruptedException e) { // ignore } } private static void addAuthorizationHeader(FullHttpRequest request, UserCredentials userCredentials) { checkNotNull(request, "request is null"); checkNotNull(userCredentials, "userCredentials is null"); byte[] encodedCredentials = Base64.getEncoder().encode(toBytes(userCredentials.username + ":" + userCredentials.password)); request.headers().add(HttpHeaderNames.AUTHORIZATION, "Basic " + newString(encodedCredentials)); } public static FullHttpRequest newRequest(HttpMethod method, String uri, UserCredentials userCredentials) { checkNotNull(method, "method is null"); checkArgument(!isNullOrEmpty(uri), "uri is null or empty"); FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri); if (userCredentials != null) { addAuthorizationHeader(request, userCredentials); } return request; } public static FullHttpRequest newRequest(HttpMethod method, String uri, String body, CharSequence contentType, UserCredentials userCredentials) { checkNotNull(method, "method is null"); checkArgument(!isNullOrEmpty(uri), "uri is null or empty"); checkNotNull(body, "body is null"); checkNotNull(contentType, "contentType is null"); checkArgument(contentType.length() > 0, "contentType is empty"); ByteBuf data = copiedBuffer(body, UTF_8); FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri, data); request.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType); request.headers().set(HttpHeaderNames.CONTENT_LENGTH, data.readableBytes()); if (userCredentials != null) { addAuthorizationHeader(request, userCredentials); } return request; } /** * Creates a new HTTP client builder. * * @return HTTP client builder */ public static Builder newBuilder() { return new Builder(); } /** * HTTP client builder. */ public static class Builder { private InetSocketAddress address; private Duration connectTimeout; private Duration operationTimeout; private Boolean acceptGzip; private Integer maxContentLength; /** * Sets server address. * * @param host the host name. * @param port the port number. * @return the builder reference */ public Builder address(String host, int port) { return address(new InetSocketAddress(host, port)); } /** * Sets server address. * * @param address the server address. * @return the builder reference */ public Builder address(InetSocketAddress address) { this.address = address; return this; } /** * Sets connection establishment timeout (by default, 10 seconds). * * @param connectTimeout connection establishment timeout. * @return the builder reference */ public Builder connectTimeout(Duration connectTimeout) { this.connectTimeout = connectTimeout; return this; } /** * Sets the amount of time before an operation is considered to have timed out (by default, 7 seconds). * * @param operationTimeout the amount of time before an operation is considered to have timed out. * @return the builder reference */ public Builder operationTimeout(Duration operationTimeout) { this.operationTimeout = operationTimeout; return this; } /** * Specifies whether or not the client accepts compressed content (by default, does not accept compressed content). * * @param acceptGzip {@code true} to accept. * @return the builder reference */ public Builder acceptGzip(boolean acceptGzip) { this.acceptGzip = acceptGzip; return this; } /** * Sets the maximum length of the response content in bytes (by default, 128 megabytes). * * @param maxContentLength the maximum length of the response content in bytes. * @return the builder reference */ public Builder maxContentLength(int maxContentLength) { this.maxContentLength = maxContentLength; return this; } /** * Builds a HTTP client. * * @return HTTP client */ public HttpClient build() { checkNotNull(address, "address is null"); if (connectTimeout == null) { connectTimeout = Duration.ofSeconds(10); } if (operationTimeout == null) { operationTimeout = Duration.ofSeconds(7); } if (acceptGzip == null) { acceptGzip = false; } if (maxContentLength == null) { maxContentLength = 128 * 1024 * 1024; } else { checkArgument(isPositive(maxContentLength), "maxContentLength should be positive"); } return new HttpClient(this); } } private static class HttpOperation { final HttpRequest request; final CompletableFuture<FullHttpResponse> response; HttpOperation(HttpRequest request, CompletableFuture<FullHttpResponse> response) { checkNotNull(request, "request is null"); checkNotNull(response, "response is null"); this.request = request; this.response = response; } } }