/**
* 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.config.NettyConfig;
import com.github.ambry.config.VerifiableProperties;
import com.github.ambry.messageformat.BlobProperties;
import com.github.ambry.notification.BlobReplicaSourceType;
import com.github.ambry.notification.NotificationBlobType;
import com.github.ambry.notification.NotificationSystem;
import com.github.ambry.router.InMemoryRouter;
import com.github.ambry.utils.TestUtils;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.DecoderResult;
import io.netty.handler.codec.http.DefaultFullHttpRequest;
import io.netty.handler.codec.http.DefaultHttpContent;
import io.netty.handler.codec.http.DefaultHttpResponse;
import io.netty.handler.codec.http.DefaultLastHttpContent;
import io.netty.handler.codec.http.HttpContent;
import io.netty.handler.codec.http.HttpMethod;
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.HttpUtil;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.handler.codec.http.multipart.DefaultHttpDataFactory;
import io.netty.handler.codec.http.multipart.FileUpload;
import io.netty.handler.codec.http.multipart.HttpDataFactory;
import io.netty.handler.codec.http.multipart.HttpPostRequestEncoder;
import io.netty.handler.codec.http.multipart.MemoryFileUpload;
import io.netty.handler.stream.ChunkedWriteHandler;
import io.netty.util.ReferenceCountUtil;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.After;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Unit tests for {@link NettyMessageProcessor}.
*/
public class NettyMessageProcessorTest {
private final InMemoryRouter router;
private final BlobStorageService blobStorageService;
private final MockRestRequestResponseHandler requestHandler;
private final HelperNotificationSystem notificationSystem = new HelperNotificationSystem();
private static final AtomicLong REQUEST_ID_GENERATOR = new AtomicLong(0);
private static final NettyMetrics NETTY_METRICS = new NettyMetrics(new MetricRegistry());
private static final NettyConfig NETTY_CONFIG = new NettyConfig(new VerifiableProperties(new Properties()));
/**
* Sets up the mock services that {@link NettyMessageProcessor} can use.
* @throws InstantiationException
* @throws IOException
*/
public NettyMessageProcessorTest() throws InstantiationException, IOException {
VerifiableProperties verifiableProperties = new VerifiableProperties(new Properties());
RestRequestMetricsTracker.setDefaults(new MetricRegistry());
router = new InMemoryRouter(verifiableProperties, notificationSystem);
requestHandler = new MockRestRequestResponseHandler();
blobStorageService = new MockBlobStorageService(verifiableProperties, requestHandler, router);
requestHandler.setBlobStorageService(blobStorageService);
blobStorageService.start();
requestHandler.start();
}
/**
* Clean up task.
*/
@After
public void cleanUp() throws IOException {
blobStorageService.shutdown();
router.close();
notificationSystem.close();
}
/**
* Tests for the common case request handling flow.
* @throws IOException
*/
@Test
public void requestHandleWithGoodInputTest() throws IOException {
doRequestHandleWithoutKeepAlive(HttpMethod.GET, RestMethod.GET);
doRequestHandleWithoutKeepAlive(HttpMethod.DELETE, RestMethod.DELETE);
doRequestHandleWithoutKeepAlive(HttpMethod.HEAD, RestMethod.HEAD);
EmbeddedChannel channel = createChannel();
doRequestHandleWithKeepAlive(channel, HttpMethod.GET, RestMethod.GET);
doRequestHandleWithKeepAlive(channel, HttpMethod.DELETE, RestMethod.DELETE);
doRequestHandleWithKeepAlive(channel, HttpMethod.HEAD, RestMethod.HEAD);
}
/**
* Tests the case where raw bytes are POSTed as chunks.
* @throws InterruptedException
*/
@Test
public void rawBytesPostTest() throws InterruptedException {
Random random = new Random();
// request also contains content.
ByteBuffer content = ByteBuffer.wrap(TestUtils.getRandomBytes(random.nextInt(128) + 128));
HttpRequest postRequest =
new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/", Unpooled.wrappedBuffer(content));
postRequest.headers().set(RestUtils.Headers.SERVICE_ID, "rawBytesPostTest");
postRequest = ReferenceCountUtil.retain(postRequest);
ByteBuffer receivedContent = doPostTest(postRequest, null);
compareContent(receivedContent, Collections.singletonList(content));
// request and content separate.
final int NUM_CONTENTS = 5;
postRequest = RestTestUtils.createRequest(HttpMethod.POST, "/", null);
List<ByteBuffer> contents = new ArrayList<ByteBuffer>(NUM_CONTENTS);
int blobSize = 0;
for (int i = 0; i < NUM_CONTENTS; i++) {
ByteBuffer buffer = ByteBuffer.wrap(TestUtils.getRandomBytes(random.nextInt(128) + 128));
blobSize += buffer.remaining();
contents.add(i, buffer);
}
postRequest.headers().set(RestUtils.Headers.SERVICE_ID, "rawBytesPostTest");
receivedContent = doPostTest(postRequest, contents);
compareContent(receivedContent, contents);
}
/**
* Tests the case where multipart upload is used.
* @throws Exception
*/
@Test
public void multipartPostTest() throws Exception {
Random random = new Random();
ByteBuffer content = ByteBuffer.wrap(TestUtils.getRandomBytes(random.nextInt(128) + 128));
HttpRequest httpRequest = RestTestUtils.createRequest(HttpMethod.POST, "/", null);
httpRequest.headers().set(RestUtils.Headers.SERVICE_ID, "rawBytesPostTest");
HttpPostRequestEncoder encoder = createEncoder(httpRequest, content);
HttpRequest postRequest = encoder.finalizeRequest();
List<ByteBuffer> contents = new ArrayList<ByteBuffer>();
while (!encoder.isEndOfInput()) {
// Sending null for ctx because the encoder is OK with that.
contents.add(encoder.readChunk(PooledByteBufAllocator.DEFAULT).content().nioBuffer());
}
ByteBuffer receivedContent = doPostTest(postRequest, contents);
compareContent(receivedContent, Collections.singletonList(content));
}
/**
* Tests for error handling flow when bad input streams are provided to the {@link NettyMessageProcessor}.
*/
@Test
public void requestHandleWithBadInputTest() throws IOException {
String content = "@@randomContent@@@";
// content without request.
EmbeddedChannel channel = createChannel();
channel.writeInbound(new DefaultLastHttpContent(Unpooled.wrappedBuffer(content.getBytes())));
HttpResponse response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.BAD_REQUEST, response.status());
assertFalse("Channel is not closed", channel.isOpen());
// content without request on a channel that was kept alive
channel = createChannel();
// send and receive response for a good request and keep the channel alive
channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, MockBlobStorageService.ECHO_REST_METHOD, null));
channel.writeInbound(LastHttpContent.EMPTY_LAST_CONTENT);
response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.OK, response.status());
// drain the content
while (channel.readOutbound() != null) {
;
}
assertTrue("Channel is not active", channel.isActive());
// send content without request
channel.writeInbound(LastHttpContent.EMPTY_LAST_CONTENT);
response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.BAD_REQUEST, response.status());
assertFalse("Channel is not closed", channel.isOpen());
// content when no content is expected.
channel = createChannel();
channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, "/", null));
channel.writeInbound(new DefaultLastHttpContent(Unpooled.wrappedBuffer(content.getBytes())));
response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.BAD_REQUEST, response.status());
assertFalse("Channel is not closed", channel.isOpen());
// wrong HTTPObject.
channel = createChannel();
channel.writeInbound(new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK));
response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.BAD_REQUEST, response.status());
assertFalse("Channel is not closed", channel.isOpen());
// request while another request is in progress.
channel = createChannel();
channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, "/", null));
channel.writeInbound(RestTestUtils.createRequest(HttpMethod.GET, "/", null));
// channel should be closed by now
assertFalse("Channel is not closed", channel.isOpen());
response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.BAD_REQUEST, response.status());
// decoding failure
channel = createChannel();
HttpRequest httpRequest = RestTestUtils.createRequest(HttpMethod.GET, "/", null);
httpRequest.setDecoderResult(DecoderResult.failure(new IllegalStateException("Induced failure")));
channel.writeInbound(httpRequest);
// channel should be closed by now
assertFalse("Channel is not closed", channel.isOpen());
response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.BAD_REQUEST, response.status());
// unsupported method
channel = createChannel();
channel.writeInbound(RestTestUtils.createRequest(HttpMethod.OPTIONS, "/", null));
// channel should be closed by now
assertFalse("Channel is not closed", channel.isOpen());
response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.BAD_REQUEST, response.status());
}
/**
* Tests for error handling flow when the {@link RestRequestHandler} throws exceptions.
*/
@Test
public void requestHandlerExceptionTest() {
try {
// RuntimeException
Properties properties = new Properties();
properties.setProperty(MockRestRequestResponseHandler.RUNTIME_EXCEPTION_ON_HANDLE, "true");
requestHandler.breakdown(new VerifiableProperties(properties));
doRequestHandlerExceptionTest(HttpMethod.GET, HttpResponseStatus.INTERNAL_SERVER_ERROR);
// RestServiceException
properties.clear();
properties.setProperty(MockRestRequestResponseHandler.REST_EXCEPTION_ON_HANDLE,
RestServiceErrorCode.InternalServerError.toString());
requestHandler.breakdown(new VerifiableProperties(properties));
doRequestHandlerExceptionTest(HttpMethod.GET, HttpResponseStatus.INTERNAL_SERVER_ERROR);
} finally {
requestHandler.fix();
}
}
// helpers
// general
/**
* Creates an {@link EmbeddedChannel} that incorporates an instance of {@link NettyMessageProcessor}.
* @return an {@link EmbeddedChannel} that incorporates an instance of {@link NettyMessageProcessor}.
*/
private EmbeddedChannel createChannel() {
NettyMessageProcessor processor = new NettyMessageProcessor(NETTY_METRICS, NETTY_CONFIG, requestHandler);
return new EmbeddedChannel(new ChunkedWriteHandler(), processor);
}
/**
* Sends the provided {@code httpRequest} and verifies that the response is an echo of the {@code restMethod}.
* @param channel the {@link EmbeddedChannel} to send the request over.
* @param httpMethod the {@link HttpMethod} for the request.
* @param restMethod the equivalent {@link RestMethod} for {@code httpMethod}. Used to check for correctness of
* response.
* @param isKeepAlive if the request needs to be keep-alive.
* @throws IOException
*/
private void sendRequestCheckResponse(EmbeddedChannel channel, HttpMethod httpMethod, RestMethod restMethod,
boolean isKeepAlive) throws IOException {
long requestId = REQUEST_ID_GENERATOR.getAndIncrement();
String uri = MockBlobStorageService.ECHO_REST_METHOD + requestId;
HttpRequest httpRequest = RestTestUtils.createRequest(httpMethod, uri, null);
HttpUtil.setKeepAlive(httpRequest, isKeepAlive);
channel.writeInbound(httpRequest);
channel.writeInbound(new DefaultLastHttpContent());
HttpResponse response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", HttpResponseStatus.OK, response.status());
// MockBlobStorageService echoes the RestMethod + request id.
String expectedResponse = restMethod.toString() + requestId;
assertEquals("Unexpected content", expectedResponse,
RestTestUtils.getContentString((HttpContent) channel.readOutbound()));
assertTrue("End marker was expected", channel.readOutbound() instanceof LastHttpContent);
}
/**
* Does the post test by sending the request and content to {@link NettyMessageProcessor} through an
* {@link EmbeddedChannel} and returns the data stored in the {@link InMemoryRouter} as a result of the post.
* @param postRequest the POST request as a {@link HttpRequest}.
* @param contentToSend the content to be sent as a part of the POST.
* @return the data stored in the {@link InMemoryRouter} as a result of the POST.
* @throws InterruptedException
*/
private ByteBuffer doPostTest(HttpRequest postRequest, List<ByteBuffer> contentToSend) throws InterruptedException {
EmbeddedChannel channel = createChannel();
// POST
notificationSystem.reset();
postRequest.headers().set(RestUtils.Headers.AMBRY_CONTENT_TYPE, "application/octet-stream");
HttpUtil.setKeepAlive(postRequest, false);
channel.writeInbound(postRequest);
if (contentToSend != null) {
for (ByteBuffer content : contentToSend) {
channel.writeInbound(new DefaultHttpContent(Unpooled.wrappedBuffer(content)));
}
channel.writeInbound(LastHttpContent.EMPTY_LAST_CONTENT);
}
if (!notificationSystem.operationCompleted.await(100, TimeUnit.MILLISECONDS)) {
fail("Post did not succeed after 100ms. There is an error or timeout needs to increase");
}
assertNotNull("Blob id operated on cannot be null", notificationSystem.blobIdOperatedOn);
return router.getActiveBlobs().get(notificationSystem.blobIdOperatedOn).getBlob();
}
/**
* Compares {@code contentToCheck} to {@code srcOfTruth}.
* @param contentToCheck the content that needs to be checked against the {@code srcOfTruth}.
* @param srcOfTruth the original content.
*/
private void compareContent(ByteBuffer contentToCheck, List<ByteBuffer> srcOfTruth) {
ByteBuffer truth;
int counter = 0;
truth = srcOfTruth.get(counter++);
while (contentToCheck.hasRemaining()) {
if (!truth.hasRemaining()) {
truth = srcOfTruth.get(counter++);
}
assertEquals("Byte in actual content differs from original content", truth.get(), contentToCheck.get());
}
}
// requestHandleWithGoodInputTest() helpers
/**
* Does a test to see that request handling with good input succeeds when channel is not keep alive.
* @param httpMethod the {@link HttpMethod} for the request.
* @param restMethod the equivalent {@link RestMethod} for {@code httpMethod}. Used to check for correctness of
* response.
* @throws IOException
*/
private void doRequestHandleWithoutKeepAlive(HttpMethod httpMethod, RestMethod restMethod) throws IOException {
EmbeddedChannel channel = createChannel();
sendRequestCheckResponse(channel, httpMethod, restMethod, false);
assertFalse("Channel not closed", channel.isOpen());
}
/**
* Does a test to see that request handling with good input succeeds when channel is keep alive.
* @param channel the {@link EmbeddedChannel} to use.
* @param httpMethod the {@link HttpMethod} for the request.
* @param restMethod the equivalent {@link RestMethod} for {@code httpMethod}. Used to check for correctness of
* response.
* @throws IOException
*/
private void doRequestHandleWithKeepAlive(EmbeddedChannel channel, HttpMethod httpMethod, RestMethod restMethod)
throws IOException {
for (int i = 0; i < 5; i++) {
sendRequestCheckResponse(channel, httpMethod, restMethod, true);
assertTrue("Channel is closed", channel.isOpen());
}
}
// multipartPostTest() helpers.
/**
* Creates a {@link HttpPostRequestEncoder} that encodes the given {@code request} and {@code blobContent}.
* @param request the {@link HttpRequest} containing headers and other metadata about the request.
* @param blobContent the {@link ByteBuffer} that represents the content of the blob.
* @return a {@link HttpPostRequestEncoder} that can encode the {@code request} and {@code blobContent}.
* @throws HttpPostRequestEncoder.ErrorDataEncoderException
* @throws IOException
*/
private HttpPostRequestEncoder createEncoder(HttpRequest request, ByteBuffer blobContent)
throws HttpPostRequestEncoder.ErrorDataEncoderException, IOException {
HttpDataFactory httpDataFactory = new DefaultHttpDataFactory(false);
HttpPostRequestEncoder encoder = new HttpPostRequestEncoder(httpDataFactory, request, true);
FileUpload fileUpload = new MemoryFileUpload(RestUtils.MultipartPost.BLOB_PART, RestUtils.MultipartPost.BLOB_PART,
"application/octet-stream", "", Charset.forName("UTF-8"), blobContent.remaining());
fileUpload.setContent(Unpooled.wrappedBuffer(blobContent));
encoder.addBodyHttpData(fileUpload);
return encoder;
}
// requestHandlerExceptionTest() helpers.
/**
* Does a test where the request handler inside {@link NettyMessageProcessor} fails. Checks for the right error code
* in the response.
* @param httpMethod the {@link HttpMethod} to use for the request.
* @param expectedStatus the excepted {@link HttpResponseStatus} in the response.
*/
private void doRequestHandlerExceptionTest(HttpMethod httpMethod, HttpResponseStatus expectedStatus) {
EmbeddedChannel channel = createChannel();
channel.writeInbound(RestTestUtils.createRequest(httpMethod, "/", null));
channel.writeInbound(new DefaultLastHttpContent());
// first outbound has to be response.
HttpResponse response = (HttpResponse) channel.readOutbound();
assertEquals("Unexpected response status", expectedStatus, response.status());
}
/**
* A notification system that helps track events in the {@link InMemoryRouter}. Not thread safe and has to be
* {@link #reset()} before every operation for which it is used.
*/
private class HelperNotificationSystem implements NotificationSystem {
/**
* The blob id of the blob that the last operation was on.
*/
protected volatile String blobIdOperatedOn = null;
/**
* Latch for awaiting the completion of an operation.
*/
protected volatile CountDownLatch operationCompleted = new CountDownLatch(1);
@Override
public void onBlobCreated(String blobId, BlobProperties blobProperties, byte[] userMetadata,
NotificationBlobType notificationBlobType) {
blobIdOperatedOn = blobId;
operationCompleted.countDown();
}
@Override
public void onBlobDeleted(String blobId, String serviceId) {
throw new IllegalStateException("Not implemented");
}
@Override
public void onBlobReplicaCreated(String sourceHost, int port, String blobId, BlobReplicaSourceType sourceType) {
throw new IllegalStateException("Not implemented");
}
@Override
public void onBlobReplicaDeleted(String sourceHost, int port, String blobId, BlobReplicaSourceType sourceType) {
throw new IllegalStateException("Not implemented");
}
@Override
public void close() {
// no op.
}
/**
* Resets the state and prepares this instance for another operation.
*/
protected void reset() {
blobIdOperatedOn = null;
operationCompleted = new CountDownLatch(1);
}
}
}