/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.flink.runtime.webmonitor.testutils;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
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.DefaultFullHttpRequest;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpContent;
import io.netty.handler.codec.http.HttpContentDecompressor;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpObject;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.util.CharsetUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import scala.concurrent.duration.FiniteDuration;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* A simple HTTP client.
*
* <pre>
* HttpTestClient client = new HttpTestClient("localhost", 8081);
* client.sendGetRequest("/overview", timeout);
* SimpleHttpResponse response = client.getNextResponse(timeout);
*
* assertEquals(200, response.getStatus().code()); // OK
* assertEquals("application/json", response.getType());
* assertTrue(response.getContent().contains("\"jobs-running\":0"));
* </pre>
*
* This code is based on Netty's HttpSnoopClient.
*
* @see <a href="https://github.com/netty/netty/blob/master/example/src/main/java/io/netty/example/http/snoop/HttpSnoopClient.java">HttpSnoopClient</a>
*/
public class HttpTestClient implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(HttpTestClient.class);
/** Target host */
private final String host;
/** Target port */
private final int port;
/** Netty's thread group for the client */
private final EventLoopGroup group;
/** Client bootstrap */
private final Bootstrap bootstrap;
/** Responses received by the client */
private final BlockingQueue<SimpleHttpResponse> responses = new LinkedBlockingQueue<>();
/**
* Creates a client instance for the server at the target host and port.
*
* @param host Host of the HTTP server
* @param port Port of the HTTP server
*/
public HttpTestClient(String host, int port) {
this.host = host;
this.port = port;
this.group = new NioEventLoopGroup();
this.bootstrap = new Bootstrap();
this.bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new HttpClientCodec());
p.addLast(new HttpContentDecompressor());
p.addLast(new ClientHandler(responses));
}
});
}
/**
* Sends a request to to the server.
*
* <pre>
* HttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/overview");
* request.headers().set(HttpHeaders.Names.HOST, host);
* request.headers().set(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE);
*
* sendRequest(request);
* </pre>
*
* @param request The {@link HttpRequest} to send to the server
*/
public void sendRequest(HttpRequest request, FiniteDuration timeout) throws InterruptedException, TimeoutException {
LOG.debug("Writing {}.", request);
// Make the connection attempt.
ChannelFuture connect = bootstrap.connect(host, port);
Channel channel;
if (connect.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
channel = connect.channel();
}
else {
throw new TimeoutException("Connection failed");
}
channel.writeAndFlush(request);
}
/**
* Sends a simple GET request to the given path. You only specify the $path part of
* http://$host:$host/$path.
*
* @param path The $path to GET (http://$host:$host/$path)
*/
public void sendGetRequest(String path, FiniteDuration timeout) throws TimeoutException, InterruptedException {
if (!path.startsWith("/")) {
path = "/" + path;
}
HttpRequest getRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1,
HttpMethod.GET, path);
getRequest.headers().set(HttpHeaders.Names.HOST, host);
getRequest.headers().set(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE);
sendRequest(getRequest, timeout);
}
/**
* Sends a simple DELETE request to the given path. You only specify the $path part of
* http://$host:$host/$path.
*
* @param path The $path to DELETE (http://$host:$host/$path)
*/
public void sendDeleteRequest(String path, FiniteDuration timeout) throws TimeoutException, InterruptedException {
if (!path.startsWith("/")) {
path = "/" + path;
}
HttpRequest getRequest = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1,
HttpMethod.DELETE, path);
getRequest.headers().set(HttpHeaders.Names.HOST, host);
getRequest.headers().set(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE);
sendRequest(getRequest, timeout);
}
/**
* Returns the next available HTTP response. A call to this method blocks until a response
* becomes available.
*
* @return The next available {@link SimpleHttpResponse}
*/
public SimpleHttpResponse getNextResponse() throws InterruptedException {
return responses.take();
}
/**
* Returns the next available HTTP response . A call to this method blocks until a response
* becomes available or throws an Exception if the timeout fires.
*
* @param timeout Timeout in milliseconds for the next response to become available
* @return The next available {@link SimpleHttpResponse}
*/
public SimpleHttpResponse getNextResponse(FiniteDuration timeout) throws InterruptedException,
TimeoutException {
SimpleHttpResponse response = responses.poll(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (response == null) {
throw new TimeoutException("No response within timeout of " + timeout + " ms");
}
else {
return response;
}
}
/**
* Closes the client.
*/
@Override
public void close() throws InterruptedException {
if (group != null) {
group.shutdownGracefully();
}
LOG.debug("Closed");
}
/**
* A simple HTTP response.
*/
public static class SimpleHttpResponse {
private final HttpResponseStatus status;
private final String type;
private final String content;
private final String location;
public SimpleHttpResponse(HttpResponseStatus status, String type, String content, String location) {
this.status = status;
this.type = type;
this.content = content;
this.location = location;
}
public HttpResponseStatus getStatus() {
return status;
}
public String getType() {
return type;
}
public final String getLocation() {
return location;
}
public String getContent() {
return content;
}
@Override
public String toString() {
return "HttpResponse(status=" + status + ", type='" + type + "'" + ", content='" +
content + "')";
}
}
/**
* The response handler. Responses from the server are handled here.
*/
@ChannelHandler.Sharable
private static class ClientHandler extends SimpleChannelInboundHandler<HttpObject> {
private final BlockingQueue<SimpleHttpResponse> responses;
private HttpResponseStatus currentStatus;
private String currentType;
private String currentLocation;
private String currentContent = "";
public ClientHandler(BlockingQueue<SimpleHttpResponse> responses) {
this.responses = responses;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws Exception {
LOG.debug("Received {}", msg);
if (msg instanceof HttpResponse) {
HttpResponse response = (HttpResponse) msg;
currentStatus = response.getStatus();
currentType = response.headers().get(HttpHeaders.Names.CONTENT_TYPE);
currentLocation = response.headers().get(HttpHeaders.Names.LOCATION);
if (HttpHeaders.isTransferEncodingChunked(response)) {
LOG.debug("Content is chunked");
}
}
if (msg instanceof HttpContent) {
HttpContent content = (HttpContent) msg;
// Add the content
currentContent += content.content().toString(CharsetUtil.UTF_8);
// Finished with this
if (content instanceof LastHttpContent) {
responses.add(new SimpleHttpResponse(currentStatus, currentType,
currentContent, currentLocation));
currentStatus = null;
currentType = null;
currentLocation = null;
currentContent = "";
ctx.close();
}
}
}
}
}