Java tutorial
/** * Copyright 2016 LinkedIn Corp. All rights reserved. * * Licensed 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. */ package com.github.ambry.rest; import com.codahale.metrics.MetricRegistry; import com.github.ambry.router.Callback; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelOutboundHandler; import io.netty.channel.ChannelOutboundHandlerAdapter; import io.netty.channel.ChannelPromise; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpContent; 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.LastHttpContent; import io.netty.handler.stream.ChunkedWriteHandler; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.ClosedChannelException; import java.text.ParseException; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicLong; import org.junit.Test; import static org.junit.Assert.*; /** * Tests functionality of {@link NettyResponseChannel}. * <p/> * To understand what each {@link TestingUri} is doing, refer to * {@link MockNettyMessageProcessor#handleRequest(HttpRequest)} and * {@link MockNettyMessageProcessor#handleContent(HttpContent)} */ public class NettyResponseChannelTest { private static final Map<RestServiceErrorCode, HttpResponseStatus> REST_ERROR_CODE_TO_HTTP_STATUS = new HashMap<>(); static { REST_ERROR_CODE_TO_HTTP_STATUS.put(RestServiceErrorCode.BadRequest, HttpResponseStatus.BAD_REQUEST); REST_ERROR_CODE_TO_HTTP_STATUS.put(RestServiceErrorCode.Unauthorized, HttpResponseStatus.UNAUTHORIZED); REST_ERROR_CODE_TO_HTTP_STATUS.put(RestServiceErrorCode.Deleted, HttpResponseStatus.GONE); REST_ERROR_CODE_TO_HTTP_STATUS.put(RestServiceErrorCode.NotFound, HttpResponseStatus.NOT_FOUND); REST_ERROR_CODE_TO_HTTP_STATUS.put(RestServiceErrorCode.ResourceScanInProgress, HttpResponseStatus.PROXY_AUTHENTICATION_REQUIRED); REST_ERROR_CODE_TO_HTTP_STATUS.put(RestServiceErrorCode.ResourceDirty, HttpResponseStatus.FORBIDDEN); REST_ERROR_CODE_TO_HTTP_STATUS.put(RestServiceErrorCode.InternalServerError, HttpResponseStatus.INTERNAL_SERVER_ERROR); } /** * Tests the common workflow of the {@link NettyResponseChannel} i.e., add some content to response body via * {@link NettyResponseChannel#write(ByteBuffer, Callback)} and then completes the response. * <p/> * For a description of what different URIs do, check {@link TestingUri}. For the actual functionality, check * {@link MockNettyMessageProcessor}). * @throws IOException */ @Test public void commonCaseTest() throws IOException { String content = "@@randomContent@@@"; String lastContent = "@@randomLastContent@@@"; EmbeddedChannel channel = createEmbeddedChannel(); AtomicLong requestIdGenerator = new AtomicLong(0); final int ITERATIONS = 5; for (int i = 0; i < 5; i++) { boolean isKeepAlive = i != (ITERATIONS - 1); String contentToSend = content + requestIdGenerator.getAndIncrement(); HttpRequest httpRequest = RestTestUtils.createRequest(HttpMethod.POST, "/", null); HttpHeaders.setKeepAlive(httpRequest, isKeepAlive); channel.writeInbound(httpRequest); channel.writeInbound(createContent(contentToSend, false)); channel.writeInbound(createContent(lastContent, true)); // first outbound has to be response. HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status", HttpResponseStatus.OK, response.getStatus()); // content echoed back. String returnedContent = RestTestUtils.getContentString((HttpContent) channel.readOutbound()); assertEquals("Content does not match with expected content", contentToSend, returnedContent); // last content echoed back. returnedContent = RestTestUtils.getContentString((HttpContent) channel.readOutbound()); assertEquals("Content does not match with expected content", lastContent, returnedContent); assertTrue("Did not receive end marker", channel.readOutbound() instanceof LastHttpContent); assertEquals("Unexpected channel state on the server", isKeepAlive, channel.isActive()); } } /** * Checks the case where no body needs to be returned but just a * {@link RestResponseChannel#onResponseComplete(Exception)} is called on the server. This should return just * response metadata. */ @Test public void noResponseBodyTest() { EmbeddedChannel channel = createEmbeddedChannel(); HttpRequest httpRequest = RestTestUtils.createRequest(HttpMethod.GET, TestingUri.ImmediateResponseComplete.toString(), null); HttpHeaders.setKeepAlive(httpRequest, false); channel.writeInbound(httpRequest); // There should be a response. HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status", HttpResponseStatus.OK, response.getStatus()); // Channel should be closed. assertFalse("Channel not closed on the server", channel.isActive()); } /** * Checks behaviour of {@link RestResponseChannel#onResponseComplete(Exception)} with valid * {@link RestServiceException}s and a {@link RuntimeException}. */ @Test public void onResponseCompleteWithExceptionTest() { for (Map.Entry<RestServiceErrorCode, HttpResponseStatus> entry : REST_ERROR_CODE_TO_HTTP_STATUS .entrySet()) { boolean shouldClose = NettyResponseChannel.CLOSE_CONNECTION_ERROR_STATUSES.contains(entry.getValue()); doOnResponseCompleteWithExceptionTest(entry.getKey(), entry.getValue(), shouldClose); } // Throws RuntimeException. There should be a INTERNAL_SERVER_ERROR HTTP response. doOnResponseCompleteWithExceptionTest(null, HttpResponseStatus.INTERNAL_SERVER_ERROR, false); } /** * Performs bad state transitions and verifies that they throw the right exceptions. * @throws Exception */ @Test public void badStateTransitionsTest() throws Exception { // write after close. doBadStateTransitionTest(TestingUri.WriteAfterClose, ClosedChannelException.class); // modify response data after it has been written to the channel doBadStateTransitionTest(TestingUri.ModifyResponseMetadataAfterWrite, IllegalStateException.class); } /** * Tests that no exceptions are thrown on repeating idempotent operations. Does <b><i>not</i></b> currently test that * state changes are idempotent. */ @Test public void idempotentOperationsTest() { doIdempotentOperationsTest(TestingUri.MultipleClose); doIdempotentOperationsTest(TestingUri.MultipleOnResponseComplete); } /** * Tests behaviour of various functions of {@link NettyResponseChannel} under write failures. * @throws Exception */ @Test public void behaviourUnderWriteFailuresTest() throws Exception { onResponseCompleteUnderWriteFailureTest(TestingUri.ImmediateResponseComplete); onResponseCompleteUnderWriteFailureTest(TestingUri.OnResponseCompleteWithNonRestException); // writing to channel with a outbound handler that generates an Exception try { String content = "@@randomContent@@@"; MockNettyMessageProcessor processor = new MockNettyMessageProcessor(); ChannelOutboundHandler badOutboundHandler = new ExceptionOutboundHandler(); EmbeddedChannel channel = new EmbeddedChannel(badOutboundHandler, processor); channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, "/", null)); // channel gets closed because of write failure channel.writeInbound(createContent(content, true)); fail("Callback for write would have thrown an Exception"); } catch (Exception e) { assertEquals("Exception not as expected", ExceptionOutboundHandler.EXCEPTION_MESSAGE, e.getMessage()); } // writing to channel with a outbound handler that generates an Error EmbeddedChannel channel = new EmbeddedChannel(new ErrorOutboundHandler(), new MockNettyMessageProcessor()); try { channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, TestingUri.WriteFailureWithThrowable.toString(), null)); } catch (Error e) { assertEquals("Unexpected error", ErrorOutboundHandler.ERROR_MESSAGE, e.getMessage()); } channel = createEmbeddedChannel(); channel.writeInbound( RestTestUtils.createRequest(HttpMethod.GET, TestingUri.ResponseFailureMidway.toString(), null)); assertFalse("Channel is not closed at the remote end", channel.isActive()); } /** * Tests handling of content that is larger than write buffer size. In this test case, the write buffer low and high * watermarks are requested to be set to 1 and 2 respectively so the content will be written byte by byte into the * {@link NettyResponseChannel}. This does <b><i>not</i></b> test for the same situation in a async scenario since * {@link EmbeddedChannel} only provides blocking semantics. * @throws IOException */ @Test public void fillWriteBufferTest() throws IOException { String content = "@@randomContent@@@"; String lastContent = "@@randomLastContent@@@"; EmbeddedChannel channel = createEmbeddedChannel(); HttpRequest httpRequest = RestTestUtils.createRequest(HttpMethod.GET, TestingUri.FillWriteBuffer.toString(), null); HttpHeaders.setKeepAlive(httpRequest, false); channel.writeInbound(httpRequest); channel.writeInbound(createContent(content, false)); channel.writeInbound(createContent(lastContent, true)); // first outbound has to be response. HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status", HttpResponseStatus.OK, response.getStatus()); // content echoed back. StringBuilder returnedContent = new StringBuilder(); while (returnedContent.length() < content.length()) { returnedContent.append(RestTestUtils.getContentString((HttpContent) channel.readOutbound())); } assertEquals("Content does not match with expected content", content, returnedContent.toString()); // last content echoed back. StringBuilder returnedLastContent = new StringBuilder(); while (returnedLastContent.length() < lastContent.length()) { returnedLastContent.append(RestTestUtils.getContentString((HttpContent) channel.readOutbound())); } assertEquals("Content does not match with expected content", lastContent, returnedLastContent.toString()); assertFalse("Channel not closed on the server", channel.isActive()); } /** * Sends a request with certain headers that will copied into the response. Checks the response for those headers to * see that values match. * @throws ParseException */ @Test public void headersPresenceTest() throws ParseException { HttpRequest request = createRequestWithHeaders(HttpMethod.GET, TestingUri.CopyHeaders.toString()); HttpHeaders.setKeepAlive(request, false); EmbeddedChannel channel = createEmbeddedChannel(); channel.writeInbound(request); HttpResponse response = (HttpResponse) channel.readOutbound(); assertFalse("Channel not closed on the server", channel.isActive()); checkHeaders(request, response); } /** * Sends null input to {@link NettyResponseChannel#setHeader(String, Object)} (through * {@link MockNettyMessageProcessor}) and tests for reaction. */ @Test public void nullHeadersSetTest() { HttpRequest request = createRequestWithHeaders(HttpMethod.GET, TestingUri.SetNullHeader.toString()); HttpHeaders.setKeepAlive(request, false); EmbeddedChannel channel = createEmbeddedChannel(); channel.writeInbound(request); HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status", HttpResponseStatus.ACCEPTED, response.getStatus()); assertFalse("Channel not closed on the server", channel.isActive()); } /** * Tries different exception scenarios for {@link NettyResponseChannel#setRequest(NettyRequest)}. */ @Test public void setRequestTest() { HttpRequest request = createRequestWithHeaders(HttpMethod.GET, TestingUri.SetRequest.toString()); HttpHeaders.setKeepAlive(request, false); EmbeddedChannel channel = createEmbeddedChannel(); channel.writeInbound(request); HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status", HttpResponseStatus.ACCEPTED, response.getStatus()); assertFalse("Channel not closed on the server", channel.isActive()); } /** * Tests setting of different available {@link ResponseStatus} codes and sees that they are recognized and converted * in {@link NettyResponseChannel}. * <p/> * If this test fails, a case for conversion probably needs to be added in {@link NettyResponseChannel}. */ @Test public void setStatusTest() { // ask for every status to be set for (ResponseStatus expectedResponseStatus : ResponseStatus.values()) { HttpRequest request = createRequestWithHeaders(HttpMethod.GET, TestingUri.SetStatus.toString()); HttpHeaders.setHeader(request, MockNettyMessageProcessor.STATUS_HEADER_NAME, expectedResponseStatus); HttpHeaders.setKeepAlive(request, false); EmbeddedChannel channel = createEmbeddedChannel(); channel.writeInbound(request); // pull but discard response channel.readOutbound(); assertFalse("Channel not closed on the server", channel.isActive()); } // check if all the ResponseStatus codes were recognized. String metricName = MetricRegistry.name(NettyResponseChannel.class, "UnknownResponseStatusCount"); long metricCount = MockNettyMessageProcessor.METRIC_REGISTRY.getCounters().get(metricName).getCount(); assertEquals("Some of the ResponseStatus codes were not recognized", 0, metricCount); } /** * Tests that HEAD returns no body in error responses. */ @Test public void noBodyForHeadTest() { EmbeddedChannel channel = createEmbeddedChannel(); for (Map.Entry<RestServiceErrorCode, HttpResponseStatus> entry : REST_ERROR_CODE_TO_HTTP_STATUS .entrySet()) { HttpHeaders httpHeaders = new DefaultHttpHeaders(); httpHeaders.set(MockNettyMessageProcessor.REST_SERVICE_ERROR_CODE_HEADER_NAME, entry.getKey()); channel.writeInbound(RestTestUtils.createRequest(HttpMethod.HEAD, TestingUri.OnResponseCompleteWithRestException.toString(), httpHeaders)); HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status", entry.getValue(), response.getStatus()); if (response instanceof FullHttpResponse) { // assert that there is no content assertEquals("The response should not contain content", 0, ((FullHttpResponse) response).content().readableBytes()); } else { HttpContent content = (HttpContent) channel.readOutbound(); assertTrue("End marker should be received", content instanceof LastHttpContent); } assertNull("There should be no more data in the channel", channel.readOutbound()); boolean shouldBeAlive = !NettyResponseChannel.CLOSE_CONNECTION_ERROR_STATUSES .contains(entry.getValue()); assertEquals("Channel state (open/close) not as expected", shouldBeAlive, channel.isActive()); assertEquals("Connection header should be consistent with channel state", shouldBeAlive, HttpHeaders.isKeepAlive(response)); if (!shouldBeAlive) { channel = createEmbeddedChannel(); } } channel.close(); } /** * Tests keep-alive for different HTTP methods and error statuses. */ @Test public void keepAliveTest() { HttpMethod[] HTTP_METHODS = { HttpMethod.POST, HttpMethod.GET, HttpMethod.HEAD, HttpMethod.DELETE }; EmbeddedChannel channel = createEmbeddedChannel(); for (HttpMethod httpMethod : HTTP_METHODS) { for (Map.Entry<RestServiceErrorCode, HttpResponseStatus> entry : REST_ERROR_CODE_TO_HTTP_STATUS .entrySet()) { HttpHeaders httpHeaders = new DefaultHttpHeaders(); httpHeaders.set(MockNettyMessageProcessor.REST_SERVICE_ERROR_CODE_HEADER_NAME, entry.getKey()); channel.writeInbound(RestTestUtils.createRequest(httpMethod, TestingUri.OnResponseCompleteWithRestException.toString(), httpHeaders)); HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status", entry.getValue(), response.getStatus()); if (!(response instanceof FullHttpResponse)) { // empty the channel while (channel.readOutbound() != null) { } } boolean shouldBeAlive = !httpMethod.equals(HttpMethod.POST) && !NettyResponseChannel.CLOSE_CONNECTION_ERROR_STATUSES.contains(entry.getValue()); assertEquals("Channel state (open/close) not as expected", shouldBeAlive, channel.isActive()); assertEquals("Connection header should be consistent with channel state", shouldBeAlive, HttpHeaders.isKeepAlive(response)); if (!shouldBeAlive) { channel = createEmbeddedChannel(); } } } channel.close(); } /** * Tests that the underlying network channel is closed when {@link NettyResponseChannel#close()} is called. */ @Test public void closeTest() { // request is keep-alive by default. HttpRequest request = createRequestWithHeaders(HttpMethod.GET, TestingUri.Close.toString()); EmbeddedChannel channel = createEmbeddedChannel(); channel.writeInbound(request); // drain the channel of content. while (channel.readOutbound() != null) { } assertFalse("Channel should be closed", channel.isOpen()); } // helpers // general /** * Creates {@link HttpContent} wrapping the {@code content}. * @param content the content to wrap. * @param isLast {@code true} if this is the last piece of content. {@code false} otherwise. * @return a {@link HttpContent} wrapping the {@code content}. */ private HttpContent createContent(String content, boolean isLast) { ByteBuf buf = Unpooled.copiedBuffer(content.getBytes()); if (isLast) { return new DefaultLastHttpContent(buf); } else { return new DefaultHttpContent(buf); } } /** * Creates a new {@link EmbeddedChannel} with a {@link ChunkedWriteHandler} and {@link MockNettyMessageProcessor} in * the pipeline. * @return the created {@link EmbeddedChannel}. */ private EmbeddedChannel createEmbeddedChannel() { ChunkedWriteHandler chunkedWriteHandler = new ChunkedWriteHandler(); MockNettyMessageProcessor processor = new MockNettyMessageProcessor(); return new EmbeddedChannel(chunkedWriteHandler, processor); } // onResponseCompleteWithExceptionTest() helpers /** * Creates a channel and sends a request (that induces an exception) to the {@link EmbeddedChannel}. Checks the * response for the {@code expectedResponseStatus}. * @param restServiceErrorCode the {@link RestServiceErrorCode} to set in the header. If {@code null}, the testing uri * {@link TestingUri#OnResponseCompleteWithNonRestException} is used. Otherwise the * testing uri {@link TestingUri#OnResponseCompleteWithRestException} is used. * @param expectedResponseStatus the {@link HttpResponseStatus} that is expected in the response. * @param shouldClose {@code true} if the channel should have been closed on this exception. {@code false} if not. */ private void doOnResponseCompleteWithExceptionTest(RestServiceErrorCode restServiceErrorCode, HttpResponseStatus expectedResponseStatus, boolean shouldClose) { HttpHeaders httpHeaders = new DefaultHttpHeaders(); TestingUri uri = TestingUri.OnResponseCompleteWithNonRestException; if (restServiceErrorCode != null) { uri = TestingUri.OnResponseCompleteWithRestException; httpHeaders.set(MockNettyMessageProcessor.REST_SERVICE_ERROR_CODE_HEADER_NAME, restServiceErrorCode); } EmbeddedChannel channel = createEmbeddedChannel(); channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, uri.toString(), httpHeaders)); HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status for " + restServiceErrorCode, expectedResponseStatus, response.getStatus()); assertEquals("Channel state (open/close) not as expected for " + restServiceErrorCode, shouldClose, !channel.isActive()); assertEquals("Connection header should be consistent with channel state for " + restServiceErrorCode, shouldClose, !HttpHeaders.isKeepAlive(response)); channel.close(); } // badStateTransitionsTest() helpers /** * Creates a channel and sends the request to the {@link EmbeddedChannel}. Checks for an exception and verifies the * exception class matches. If {@code exceptionClass} is {@link RestServiceException}, then checks the provided * {@link RestServiceErrorCode}. * @param uri the uri to hit. * @param exceptionClass the class of the exception expected. * @throws Exception */ private void doBadStateTransitionTest(TestingUri uri, Class exceptionClass) throws Exception { EmbeddedChannel channel = createEmbeddedChannel(); try { channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, uri.toString(), null)); fail("This test was expecting the handler in the channel to throw an exception"); } catch (Exception e) { if (!exceptionClass.isInstance(e)) { throw e; } } } // idempotentOperationsTest() helpers /** * Checks that idempotent operations do not throw exceptions when called multiple times. Does <b><i>not</i></b> * currently test that state changes are idempotent. * @param uri the uri to be hit. */ private void doIdempotentOperationsTest(TestingUri uri) { EmbeddedChannel channel = createEmbeddedChannel(); // no exceptions. channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, uri.toString(), null)); HttpResponse response = (HttpResponse) channel.readOutbound(); assertEquals("Unexpected response status", HttpResponseStatus.OK, response.getStatus()); } /** * Checks that no exceptions are thrown by {@link RestResponseChannel#onResponseComplete(Exception)} when * there are write failures. * @param uri the uri to hit. */ private void onResponseCompleteUnderWriteFailureTest(TestingUri uri) { MockNettyMessageProcessor processor = new MockNettyMessageProcessor(); ExceptionOutboundHandler exceptionOutboundHandler = new ExceptionOutboundHandler(); EmbeddedChannel channel = new EmbeddedChannel(exceptionOutboundHandler, processor); // no exception because onResponseComplete() swallows it. channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, uri.toString(), null)); assertFalse("Channel is not closed at the remote end", channel.isActive()); } // headersPresenceTest() helpers /** * Creates a {@link HttpRequest} with some headers set that will be checked on response. * @param httpMethod the {@link HttpMethod} desired. * @param uri the URI to hit. * @return a link {@link HttpRequest} with some headers set. */ private HttpRequest createRequestWithHeaders(HttpMethod httpMethod, String uri) { long currentTime = System.currentTimeMillis(); HttpRequest request = RestTestUtils.createRequest(httpMethod, uri, null); HttpHeaders.setHeader(request, HttpHeaders.Names.CONTENT_TYPE, "dummy/content-type"); HttpHeaders.setHeader(request, HttpHeaders.Names.CONTENT_LENGTH, 100); HttpHeaders.setHeader(request, HttpHeaders.Names.LOCATION, "dummyLocation"); HttpHeaders.setDateHeader(request, HttpHeaders.Names.LAST_MODIFIED, new Date(currentTime)); HttpHeaders.setDateHeader(request, HttpHeaders.Names.EXPIRES, new Date(currentTime + 1)); HttpHeaders.setHeader(request, HttpHeaders.Names.CACHE_CONTROL, "dummyCacheControl"); HttpHeaders.setHeader(request, HttpHeaders.Names.PRAGMA, "dummyPragma"); HttpHeaders.setDateHeader(request, HttpHeaders.Names.DATE, new Date(currentTime + 2)); HttpHeaders.setHeader(request, MockNettyMessageProcessor.CUSTOM_HEADER_NAME, "customHeaderValue"); return request; } /** * Checks the headers in the response match those in the request. * @param request the {@link HttpRequest} with the original value of the headers. * @param response the {@link HttpResponse} that should have the same value for some headers in {@code request}. * @throws ParseException */ private void checkHeaders(HttpRequest request, HttpResponse response) throws ParseException { assertEquals("Unexpected response status", HttpResponseStatus.ACCEPTED, response.getStatus()); assertEquals(HttpHeaders.Names.CONTENT_TYPE + " does not match", HttpHeaders.getHeader(request, HttpHeaders.Names.CONTENT_TYPE), HttpHeaders.getHeader(response, HttpHeaders.Names.CONTENT_TYPE)); assertEquals(HttpHeaders.Names.CONTENT_LENGTH + " does not match", HttpHeaders.getHeader(request, HttpHeaders.Names.CONTENT_LENGTH), HttpHeaders.getHeader(response, HttpHeaders.Names.CONTENT_LENGTH)); assertEquals(HttpHeaders.Names.LOCATION + " does not match", HttpHeaders.getHeader(request, HttpHeaders.Names.LOCATION), HttpHeaders.getHeader(response, HttpHeaders.Names.LOCATION)); assertEquals(HttpHeaders.Names.LAST_MODIFIED + " does not match", HttpHeaders.getDateHeader(request, HttpHeaders.Names.LAST_MODIFIED), HttpHeaders.getDateHeader(response, HttpHeaders.Names.LAST_MODIFIED)); assertEquals(HttpHeaders.Names.EXPIRES + " does not match", HttpHeaders.getDateHeader(request, HttpHeaders.Names.EXPIRES), HttpHeaders.getDateHeader(response, HttpHeaders.Names.EXPIRES)); assertEquals(HttpHeaders.Names.CACHE_CONTROL + " does not match", HttpHeaders.getHeader(request, HttpHeaders.Names.CACHE_CONTROL), HttpHeaders.getHeader(response, HttpHeaders.Names.CACHE_CONTROL)); assertEquals(HttpHeaders.Names.PRAGMA + " does not match", HttpHeaders.getHeader(request, HttpHeaders.Names.PRAGMA), HttpHeaders.getHeader(response, HttpHeaders.Names.PRAGMA)); assertEquals(HttpHeaders.Names.DATE + " does not match", HttpHeaders.getDateHeader(request, HttpHeaders.Names.DATE), HttpHeaders.getDateHeader(response, HttpHeaders.Names.DATE)); assertEquals(MockNettyMessageProcessor.CUSTOM_HEADER_NAME + " does not match", HttpHeaders.getHeader(request, MockNettyMessageProcessor.CUSTOM_HEADER_NAME), HttpHeaders.getHeader(response, MockNettyMessageProcessor.CUSTOM_HEADER_NAME)); } } /** * List of all the testing URIs. */ enum TestingUri { /** * When this request is received, {@link NettyResponseChannel#close()} is called immediately. */ Close, /** * When this request is received, headers from the request are copied into the response channel. */ CopyHeaders, /** * When this request is received, {@link RestResponseChannel#onResponseComplete(Exception)} is called * immediately with null {@code cause}. */ ImmediateResponseComplete, /** * Reduces the write buffer low and high watermarks to 1 and 2 bytes respectively in * {@link io.netty.channel.ChannelConfig} so that data is written to the channel byte by byte. This simulates filling * up of write buffer (but does not test async writing and flushing since {@link EmbeddedChannel} is blocking). */ FillWriteBuffer, /** * When this request is received, some data is initially written to the channel via * {@link NettyResponseChannel#write(ByteBuffer, Callback)} . An attempt to modify response headers (metadata) is made * after this. */ ModifyResponseMetadataAfterWrite, /** * When this request is received, {@link NettyResponseChannel#close()} is called multiple times. */ MultipleClose, /** * When this request is received, {@link RestResponseChannel#onResponseComplete(Exception)} is called * multiple times. */ MultipleOnResponseComplete, /** * When this request is received, {@link RestResponseChannel#onResponseComplete(Exception)} is called * immediately with a {@link RestServiceException} as {@code cause}. The exception message and error code is the * {@link RestServiceErrorCode} passed in as the value of the header * {@link MockNettyMessageProcessor#REST_SERVICE_ERROR_CODE_HEADER_NAME}. */ OnResponseCompleteWithRestException, /** * When this request is received, {@link RestResponseChannel#onResponseComplete(Exception)} is called * immediately with a {@link RuntimeException} as {@code cause}. The exception message is the URI string. */ OnResponseCompleteWithNonRestException, /** * Response sending fails midway through a write. */ ResponseFailureMidway, /** * When this request is received, {@link NettyResponseChannel#setHeader(String, Object)} is attempted with null * arguments. If these calls don't fail, we report an error. */ SetNullHeader, /** * Tests setting of a {@link NettyRequest} in {@link NettyResponseChannel}. */ SetRequest, /** * Requests a certain status to be set. */ SetStatus, /** * When this request is received, the {@link NettyResponseChannel} is closed and then a write operation is attempted. */ WriteAfterClose, /** * Fail a write with a {@link Throwable} to test reactions. */ WriteFailureWithThrowable, /** * Catch all TestingUri. */ Unknown; /** * Converts the uri specified by the input string into a {@link TestingUri}. * @param uri the TestingUri as a string. * @return the uri requested as a valid {@link TestingUri} if uri is known, otherwise returns {@link #Unknown} */ public static TestingUri getTestingURI(String uri) { try { return TestingUri.valueOf(uri); } catch (IllegalArgumentException e) { return TestingUri.Unknown; } } } /** * A test handler that forms the pipeline of the {@link EmbeddedChannel} used in tests. * <p/> * Exposes some URI strings through which a predefined flow can be executed and verified. */ class MockNettyMessageProcessor extends SimpleChannelInboundHandler<HttpObject> { static final MetricRegistry METRIC_REGISTRY = new MetricRegistry(); static final String CUSTOM_HEADER_NAME = "customHeader"; static final String STATUS_HEADER_NAME = "status"; static final String REST_SERVICE_ERROR_CODE_HEADER_NAME = "restServiceErrorCode"; private ChannelHandlerContext ctx; private NettyRequest request; private NettyResponseChannel restResponseChannel; private NettyMetrics nettyMetrics; @Override public void channelActive(ChannelHandlerContext ctx) { this.ctx = ctx; nettyMetrics = new NettyMetrics(METRIC_REGISTRY); RestRequestMetricsTracker.setDefaults(METRIC_REGISTRY); } @Override public void channelInactive(ChannelHandlerContext ctx) { request = null; restResponseChannel = null; } @Override public void channelRead0(ChannelHandlerContext ctx, HttpObject obj) throws Exception { if (obj != null && obj instanceof HttpRequest) { if (obj.getDecoderResult().isSuccess()) { handleRequest((HttpRequest) obj); } else { throw new RestServiceException("Malformed request received - " + obj, RestServiceErrorCode.MalformedRequest); } } else if (obj != null && obj instanceof HttpContent) { handleContent((HttpContent) obj); } else { throw new RestServiceException("HttpObject received is null or not of a known type", RestServiceErrorCode.UnknownHttpObject); } } /** * Handles a {@link HttpRequest}. If content is awaited, handles some state maintenance. Else handles the request * according to a predefined flow based on the uri. * @param httpRequest the {@link HttpRequest} that needs to be handled. * @throws Exception */ private void handleRequest(HttpRequest httpRequest) throws Exception { request = new NettyRequest(httpRequest, nettyMetrics); restResponseChannel = new NettyResponseChannel(ctx, nettyMetrics); restResponseChannel.setRequest(request); restResponseChannel.setHeader(RestUtils.Headers.CONTENT_TYPE, "text/plain; charset=UTF-8"); TestingUri uri = TestingUri.getTestingURI(request.getUri()); switch (uri) { case Close: restResponseChannel.close(); assertFalse("Request channel is not closed", request.isOpen()); break; case CopyHeaders: copyHeaders(httpRequest); restResponseChannel.onResponseComplete(null); assertFalse("Request channel is not closed", request.isOpen()); break; case ImmediateResponseComplete: restResponseChannel.onResponseComplete(null); assertFalse("Request channel is not closed", request.isOpen()); break; case FillWriteBuffer: ctx.channel().config().setWriteBufferLowWaterMark(1); ctx.channel().config().setWriteBufferHighWaterMark(2); break; case ModifyResponseMetadataAfterWrite: restResponseChannel.write(ByteBuffer.wrap(new byte[0]), null); restResponseChannel.setHeader(RestUtils.Headers.CONTENT_TYPE, "text/plain; charset=UTF-8"); break; case MultipleClose: restResponseChannel.onResponseComplete(null); assertFalse("Request channel is not closed", request.isOpen()); restResponseChannel.close(); restResponseChannel.close(); break; case MultipleOnResponseComplete: restResponseChannel.onResponseComplete(null); assertFalse("Request channel is not closed", request.isOpen()); restResponseChannel.onResponseComplete(null); break; case OnResponseCompleteWithRestException: String errorCodeStr = (String) request.getArgs().get(REST_SERVICE_ERROR_CODE_HEADER_NAME); RestServiceErrorCode errorCode = RestServiceErrorCode.valueOf(errorCodeStr); restResponseChannel.onResponseComplete(new RestServiceException(errorCodeStr, errorCode)); assertFalse("Request channel is not closed", request.isOpen()); break; case OnResponseCompleteWithNonRestException: restResponseChannel.onResponseComplete( new RuntimeException(TestingUri.OnResponseCompleteWithNonRestException.toString())); assertFalse("Request channel is not closed", request.isOpen()); break; case ResponseFailureMidway: ChannelWriteCallback callback = new ChannelWriteCallback(); callback.compareWithFuture(restResponseChannel .write(ByteBuffer.wrap(TestingUri.ResponseFailureMidway.toString().getBytes()), callback)); assertNull("There shouldn't have been any exceptions on the first write", callback.exception); restResponseChannel.onResponseComplete(new Exception()); // this should close the channel and the test will check for that. break; case SetNullHeader: setNullHeaders(); break; case SetRequest: setRequestTest(); break; case SetStatus: restResponseChannel .setStatus(ResponseStatus.valueOf(HttpHeaders.getHeader(httpRequest, STATUS_HEADER_NAME))); restResponseChannel.onResponseComplete(null); assertFalse("Request channel is not closed", request.isOpen()); break; case WriteAfterClose: restResponseChannel.close(); assertFalse("Request channel is not closed", request.isOpen()); callback = new ChannelWriteCallback(); callback.compareWithFuture(restResponseChannel .write(ByteBuffer.wrap(TestingUri.WriteAfterClose.toString().getBytes()), callback)); if (callback.exception != null) { throw callback.exception; } break; case WriteFailureWithThrowable: callback = new ChannelWriteCallback(); callback.compareWithFuture(restResponseChannel .write(ByteBuffer.wrap(TestingUri.WriteFailureWithThrowable.toString().getBytes()), callback)); break; } } /** * Handles a {@link HttpContent}. Checks state and echoes back the content. * @param httpContent the {@link HttpContent} that needs to be handled. * @throws Exception */ private void handleContent(HttpContent httpContent) throws Exception { if (request != null) { boolean isLast = httpContent instanceof LastHttpContent; ByteBuffer content = ByteBuffer.wrap(httpContent.content().array()); int bytesWritten = 0; while (content.hasRemaining()) { ChannelWriteCallback callback = new ChannelWriteCallback(); callback.compareWithFuture(restResponseChannel.write(content, callback)); if (callback.exception == null) { bytesWritten += callback.result; } else { throw callback.exception; } } assertEquals("Bytes written not equal to content size", httpContent.content().array().length, bytesWritten); if (isLast) { restResponseChannel.onResponseComplete(null); assertFalse("Request channel is not closed", request.isOpen()); } } else { throw new RestServiceException("Received data without a request", RestServiceErrorCode.InvalidRequestState); } } /** * Copies headers from request to response. * @param httpRequest the {@link HttpRequest} to copy headers from. * @throws ParseException * @throws RestServiceException */ private void copyHeaders(HttpRequest httpRequest) throws ParseException, RestServiceException { restResponseChannel.setStatus(ResponseStatus.Accepted); restResponseChannel.setHeader(RestUtils.Headers.CONTENT_TYPE, HttpHeaders.getHeader(httpRequest, HttpHeaders.Names.CONTENT_TYPE)); restResponseChannel.setHeader(RestUtils.Headers.CONTENT_LENGTH, Long.parseLong(HttpHeaders.getHeader(httpRequest, HttpHeaders.Names.CONTENT_LENGTH))); restResponseChannel.setHeader(RestUtils.Headers.LOCATION, HttpHeaders.getHeader(httpRequest, HttpHeaders.Names.LOCATION)); restResponseChannel.setHeader(RestUtils.Headers.LAST_MODIFIED, HttpHeaders.getDateHeader(httpRequest, HttpHeaders.Names.LAST_MODIFIED)); restResponseChannel.setHeader(RestUtils.Headers.EXPIRES, HttpHeaders.getDateHeader(httpRequest, HttpHeaders.Names.EXPIRES)); restResponseChannel.setHeader(RestUtils.Headers.CACHE_CONTROL, HttpHeaders.getHeader(httpRequest, HttpHeaders.Names.CACHE_CONTROL)); restResponseChannel.setHeader(RestUtils.Headers.PRAGMA, HttpHeaders.getHeader(httpRequest, HttpHeaders.Names.PRAGMA)); restResponseChannel.setHeader(RestUtils.Headers.DATE, HttpHeaders.getDateHeader(httpRequest, HttpHeaders.Names.DATE)); restResponseChannel.setHeader(CUSTOM_HEADER_NAME, HttpHeaders.getHeader(httpRequest, CUSTOM_HEADER_NAME)); } /** * Tries to set null headers in the {@link NettyResponseChannel}. If the operation does not fail, reports an error. * @throws RestServiceException */ private void setNullHeaders() throws RestServiceException { ResponseStatus status = ResponseStatus.Accepted; try { // headerName null. try { restResponseChannel.setHeader(null, "dummyHeaderValue"); status = ResponseStatus.InternalServerError; fail("Call to setHeader with null values succeeded. It should have not"); } catch (IllegalArgumentException e) { // expected. nothing to do. } // headerValue null. try { restResponseChannel.setHeader("dummyHeaderName", null); status = ResponseStatus.InternalServerError; fail("Call to setHeader with null values succeeded. It should have not"); } catch (IllegalArgumentException e) { // expected. nothing to do. } // headerName and headerValue null. try { restResponseChannel.setHeader(null, null); status = ResponseStatus.InternalServerError; fail("Call to setHeader with null values succeeded. It should have not"); } catch (IllegalArgumentException e) { // expected. nothing to do. } } finally { restResponseChannel.setStatus(status); restResponseChannel.onResponseComplete(null); assertFalse("Request channel is not closed", request.isOpen()); } } /** * Tries different exception scenarios for {@link NettyResponseChannel#setRequest(NettyRequest)}. * @throws RestServiceException */ private void setRequestTest() throws RestServiceException { ResponseStatus status = ResponseStatus.Accepted; restResponseChannel = new NettyResponseChannel(ctx, new NettyMetrics(new MetricRegistry())); try { try { restResponseChannel.setRequest(null); status = ResponseStatus.InternalServerError; fail("Tried to set null request yet no exception was thrown"); } catch (IllegalArgumentException e) { // expected. Nothing to do. } restResponseChannel.setRequest(request); try { restResponseChannel.setRequest(request); status = ResponseStatus.InternalServerError; fail("Tried to reset request and no exception was thrown"); } catch (IllegalStateException e) { // expected. Nothing to do. } } finally { restResponseChannel.setStatus(status); restResponseChannel.onResponseComplete(null); assertFalse("Request channel is not closed", request.isOpen()); } } } /** * A {@link ChannelOutboundHandler} that throws exceptions on write. */ class ExceptionOutboundHandler extends ChannelOutboundHandlerAdapter { protected static String EXCEPTION_MESSAGE = "@@randomExceptionMessage@@"; @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { throw new Exception(EXCEPTION_MESSAGE); } } /** * A {@link ChannelOutboundHandler} that throws errors on write. */ class ErrorOutboundHandler extends ChannelOutboundHandlerAdapter { protected static String ERROR_MESSAGE = "@@randomErrorMessage@@"; @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { throw new Error(ERROR_MESSAGE); } } /** * Class that can be used to receive callbacks from {@link NettyResponseChannel}. * <p/> * On callback, stores the result and exception to be retrieved for later use. */ class ChannelWriteCallback implements Callback<Long> { /** * Contains the result of the operation for which this was set as callback. * If there was no result or if this was called before callback is received, it will be null */ public Long result = null; /** * Stores any exception thrown by the operation for which this was set as callback. * If there was no exception or if this was called before callback is received, it will be null. */ public Exception exception = null; private CountDownLatch callbackReceived = new CountDownLatch(1); @Override public void onCompletion(Long result, Exception exception) { this.result = result; this.exception = exception; callbackReceived.countDown(); } /** * Compares the data obtained from the callback with the data obtained from {@code future}. * @param future the {@link Future} that represents the result of the same operation that this callback is meant for. * @throws InterruptedException * @throws TimeoutException */ public void compareWithFuture(Future<Long> future) throws InterruptedException, TimeoutException { Long futureOutput; try { futureOutput = future.get(1, TimeUnit.MILLISECONDS); assertEquals("Future and callback results don't match", futureOutput, result); } catch (ExecutionException e) { if (!callbackReceived.await(1, TimeUnit.MILLISECONDS)) { throw new IllegalStateException( "Callback has not been invoked even though future.get() has returned"); } else { assertEquals("Future and callback exceptions don't match", e.getCause().getMessage(), exception.getMessage()); } } } }