package ch.loway.oss.ari4java.tools.http; import ch.loway.oss.ari4java.tools.*; import ch.loway.oss.ari4java.tools.HttpResponse; import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelPipeline; import io.netty.channel.EventLoopGroup; 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.codec.http.websocketx.*; import io.netty.util.concurrent.ScheduledFuture; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Iterator; import java.util.List; import java.util.concurrent.TimeUnit; /** * HTTP and WebSocket client implementation based on netty.io. * * Threading is handled by NioEventLoopGroup, which selects on multiple * sockets and provides threads to handle the events on the sockets. * * Requires netty-all-4.0.12.Final.jar * * @author mwalton * */ public class NettyHttpClient implements HttpClient, WsClient, WsClientAutoReconnect { public static final int MAX_HTTP_REQUEST_KB = 256; private Bootstrap bootStrap; private URI baseUri; private EventLoopGroup group; private EventLoopGroup shutDownGroup; private String username; private String password; private HttpResponseHandler wsCallback; private String wsEventsUrl; private List<HttpParam> wsEventsParamQuery; private WsClientConnection wsClientConnection; private int reconnectCount = 0; private ChannelFuture wsChannelFuture; private ScheduledFuture<?> wsPingTimer = null; private NettyWSClientHandler wsHandler; private ChannelFutureListener wsFuture; public NettyHttpClient() { group = new NioEventLoopGroup(); shutDownGroup = new NioEventLoopGroup(); } public void initialize(String baseUrl, String username, String password) throws URISyntaxException { this.username = username; this.password = password; baseUri = new URI(baseUrl); String protocol = baseUri.getScheme(); if (!"http".equals(protocol)) { throw new IllegalArgumentException("Unsupported protocol: " + protocol); } // Bootstrap is the factory for HTTP connections bootStrap = new Bootstrap(); bootStrap.group(group); bootStrap.channel(NioSocketChannel.class); bootStrap.handler(new ChannelInitializer<SocketChannel>() { @Override public void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast("http-codec", new HttpClientCodec()); pipeline.addLast("aggregator", new HttpObjectAggregator( MAX_HTTP_REQUEST_KB * 1024)); pipeline.addLast("http-handler", new NettyHttpClientHandler()); } }); } public void destroy() { // use a different event group to execute the shutdown to avoid deadlocks shutDownGroup.schedule(new Runnable() { @Override public void run() { if (wsClientConnection != null) { try { wsClientConnection.disconnect(); } catch (RestException e) { // not bubbling exception up, just ignoring } } if (group != null && !group.isShuttingDown()) { group.shutdownGracefully(5, 10, TimeUnit.SECONDS).syncUninterruptibly(); group = null; } } }, 250L, TimeUnit.MILLISECONDS); } private String buildURL(String path, List<HttpParam> parametersQuery) throws UnsupportedEncodingException { StringBuilder uriBuilder = new StringBuilder(); uriBuilder.append(baseUri.getPath()); uriBuilder.append("ari"); uriBuilder.append(path); uriBuilder.append("?api_key="); uriBuilder.append(URLEncoder.encode(username, "UTF-8")); uriBuilder.append(":"); uriBuilder.append(URLEncoder.encode(password, "UTF-8")); if (parametersQuery != null) { for (HttpParam hp : parametersQuery) { if (hp.value != null && !hp.value.isEmpty()) { uriBuilder.append("&"); uriBuilder.append(hp.name); uriBuilder.append("="); uriBuilder.append(URLEncoder.encode(hp.value, "UTF-8")); } } } return uriBuilder.toString(); } // Factory for WS handshakes private WebSocketClientHandshaker getWsHandshake(String path, List<HttpParam> parametersQuery) throws UnsupportedEncodingException { String url = buildURL(path, parametersQuery); try { URI uri = new URI(url.replaceFirst("http", "ws")); return WebSocketClientHandshakerFactory.newHandshaker( uri, WebSocketVersion.V13, null, false, null); } catch (URISyntaxException e) { e.printStackTrace(); return null; } } // Build the HTTP request based on the given parameters private HttpRequest buildRequest(String path, String method, List<HttpParam> parametersQuery, List<HttpParam> parametersForm, List<HttpParam> parametersBody) throws UnsupportedEncodingException { String url = buildURL(path, parametersQuery); FullHttpRequest request = new DefaultFullHttpRequest( HttpVersion.HTTP_1_1, HttpMethod.valueOf(method), url); //System.out.println(request.getUri()); if (parametersBody != null && !parametersBody.isEmpty()) { String vars = makeBodyVariables(parametersBody); ByteBuf bbuf = Unpooled.copiedBuffer(vars, StandardCharsets.UTF_8); request.headers().add(HttpHeaders.Names.CONTENT_TYPE, "application/json"); request.headers().set(HttpHeaders.Names.CONTENT_LENGTH, bbuf.readableBytes()); request.content().clear().writeBytes(bbuf); } request.headers().set(HttpHeaders.Names.HOST, "localhost"); request.headers().set(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE); return request; } private String makeBodyVariables(List<HttpParam> variables) { StringBuilder varBuilder = new StringBuilder(); varBuilder.append("{").append("\"variables\": {"); Iterator<HttpParam> entryIterator = variables.iterator(); while(entryIterator.hasNext()) { HttpParam param = entryIterator.next(); varBuilder.append("\"").append(param.name).append("\"").append(": ").append("\"").append(param.value).append("\""); if (entryIterator.hasNext()) { varBuilder.append(","); } } varBuilder.append("}}"); return varBuilder.toString(); } private RestException makeException(HttpResponseStatus status, String response, List<HttpResponse> errors) { if (status == null ) { return new RestException("Shutdown: " + response); } for (HttpResponse hr : errors) { if (hr.code == status.code()) { return new RestException(hr.description); } } return new RestException(response); } // Synchronous HTTP action @Override public String httpActionSync(String uri, String method, List<HttpParam> parametersQuery, List<HttpParam> parametersForm, List<HttpParam> parametersBody, List<HttpResponse> errors) throws RestException { Channel ch; try { HttpRequest request = buildRequest(uri, method, parametersQuery, parametersForm, parametersBody); //handler.reset(); ch = bootStrap.connect(baseUri.getHost(), baseUri.getPort()).sync().channel(); NettyHttpClientHandler handler = (NettyHttpClientHandler) ch.pipeline().get("http-handler"); ch.writeAndFlush(request); ch.closeFuture().sync(); if ( httpResponseOkay(handler.getResponseStatus())) { return handler.getResponseText(); } else { throw makeException(handler.getResponseStatus(), handler.getResponseText(), errors); } } catch (UnsupportedEncodingException e) { throw new RestException(e); } catch (InterruptedException e) { throw new RestException(e); } } // Asynchronous HTTP action, response is passed to HttpResponseHandler @Override public void httpActionAsync(String uri, String method, List<HttpParam> parametersQuery, List<HttpParam> parametersForm, List<HttpParam> parametersBody, final List<HttpResponse> errors, final HttpResponseHandler responseHandler) throws RestException { try { final HttpRequest request = buildRequest(uri, method, parametersQuery, parametersForm, parametersBody); // Get future channel ChannelFuture cf = bootStrap.connect(baseUri.getHost(), baseUri.getPort()); cf.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { if (future.isSuccess()) { Channel ch = future.channel(); responseHandler.onChReadyToWrite(); ch.writeAndFlush(request); ch.closeFuture().addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { responseHandler.onResponseReceived(); if (future.isSuccess()) { NettyHttpClientHandler handler = (NettyHttpClientHandler) future.channel().pipeline().get("http-handler"); HttpResponseStatus rStatus = handler.getResponseStatus(); if ( httpResponseOkay(rStatus)) { responseHandler.onSuccess(handler.getResponseText()); } else { responseHandler.onFailure(makeException(handler.getResponseStatus(), handler.getResponseText(), errors)); } } else { responseHandler.onFailure(future.cause()); } } }); } else { responseHandler.onFailure(future.cause()); } } }); } catch (UnsupportedEncodingException e) { throw new RestException(e); } } // WsClient implementation - connect to WebSocket server @Override public WsClientConnection connect(final HttpResponseHandler callback, final String url, final List<HttpParam> lParamQuery) throws RestException { this.wsCallback = callback; this.wsEventsUrl = url; this.wsEventsParamQuery = lParamQuery; try { this.wsHandler = new NettyWSClientHandler(getWsHandshake(url, lParamQuery), callback, this); } catch (UnsupportedEncodingException e) { throw new RestException(e); } Bootstrap wsBootStrap = new Bootstrap(); wsBootStrap.group(group); wsBootStrap.channel(NioSocketChannel.class); wsBootStrap.handler(new ChannelInitializer<SocketChannel>() { @Override public void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast("http-codec", new HttpClientCodec()); pipeline.addLast("aggregator", new HttpObjectAggregator(MAX_HTTP_REQUEST_KB * 1024)); pipeline.addLast("ws-handler", wsHandler); } }); wsChannelFuture = wsBootStrap.connect(baseUri.getHost(), baseUri.getPort()); wsFuture = new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { if (future.isSuccess()) { callback.onChReadyToWrite(); // reset the reconnect counter on successful connect reconnectCount = 0; } else { if (reconnectCount >= 10) { callback.onFailure(future.cause()); } else { reconnectWs(); } } } }; wsChannelFuture.addListener(wsFuture); // start a ws ping schedule startPing(); // Provide disconnection handle to client return createWsClientConnection(); } private void startPing() { if (wsPingTimer == null) { wsPingTimer = group.scheduleAtFixedRate(new Runnable() { @Override public void run() { if (System.currentTimeMillis() - wsCallback.getLastResponseTime() > 15000) { if (!wsChannelFuture.isCancelled() && wsChannelFuture.channel() != null) { WebSocketFrame frame = new PingWebSocketFrame(Unpooled.wrappedBuffer("ari4j".getBytes( StandardCharsets.UTF_8 ))); wsChannelFuture.channel().writeAndFlush(frame); } } } }, 5, 5, TimeUnit.MINUTES); } } private WsClientConnection createWsClientConnection() { if (this.wsClientConnection == null) { this.wsClientConnection = new WsClientConnection() { @Override public void disconnect() throws RestException { wsHandler.setShuttingDown(true); Channel ch = wsChannelFuture.channel(); if (ch != null) { // NettyWSClientHandler will close the connection when the server // responds to the CloseWebSocketFrame. ch.writeAndFlush(new CloseWebSocketFrame()); // if the server is no longer there then close any way ch.close(); } wsChannelFuture.removeListener(wsFuture); } }; } return this.wsClientConnection; } /** * Checks if a response is okay. * All 2XX responses are supposed to be okay. * * @param status * @return whether it is a 2XX code or not (error!) */ private boolean httpResponseOkay(HttpResponseStatus status) { if (HttpResponseStatus.OK.equals(status) || HttpResponseStatus.NO_CONTENT.equals(status) || HttpResponseStatus.ACCEPTED.equals(status) || HttpResponseStatus.CREATED.equals(status)) { return true; } else { return false; } } @Override public void reconnectWs() { // cancel the ping timer if (wsPingTimer != null) { wsPingTimer.cancel(false); wsPingTimer = null; } // if not shutdown reconnect, note the check not on the shutDownGroup if (!group.isShuttingDown()) { // schedule reconnect after a 2,5,10 seconds long[] timeouts = {2L, 5L, 10L}; reconnectCount++; shutDownGroup.schedule(new Runnable() { @Override public void run() { try { // 1st close up wsClientConnection.disconnect(); System.err.println(System.currentTimeMillis() + " ** connecting... try:" + reconnectCount + " ++"); // then connect again connect(wsCallback, wsEventsUrl, wsEventsParamQuery); } catch (RestException e) { wsCallback.onFailure(e); } } }, reconnectCount >= timeouts.length ? timeouts[timeouts.length - 1] : timeouts[reconnectCount], TimeUnit.SECONDS); } } }