/* * Copyright © 2014-2016 Cask Data, Inc. * * 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. See the * License for the specific language governing permissions and limitations under * the License. */ package co.cask.cdap.internal.app.runtime.service.http; import co.cask.cdap.api.Admin; import co.cask.cdap.api.Transactional; import co.cask.cdap.api.app.ApplicationSpecification; import co.cask.cdap.api.data.DatasetInstantiationException; import co.cask.cdap.api.dataset.Dataset; import co.cask.cdap.api.dataset.DatasetProperties; import co.cask.cdap.api.dataset.InstanceNotFoundException; import co.cask.cdap.api.metrics.MetricsContext; import co.cask.cdap.api.plugin.PluginProperties; import co.cask.cdap.api.service.http.AbstractHttpServiceHandler; import co.cask.cdap.api.service.http.HttpContentConsumer; import co.cask.cdap.api.service.http.HttpContentProducer; import co.cask.cdap.api.service.http.HttpServiceContext; import co.cask.cdap.api.service.http.HttpServiceHandler; import co.cask.cdap.api.service.http.HttpServiceHandlerSpecification; import co.cask.cdap.api.service.http.HttpServiceRequest; import co.cask.cdap.api.service.http.HttpServiceResponder; import co.cask.cdap.common.io.Locations; import co.cask.cdap.common.metrics.NoOpMetricsCollectionService; import co.cask.http.HttpHandler; import co.cask.http.NettyHttpService; import co.cask.tephra.TransactionAware; import co.cask.tephra.TransactionContext; import co.cask.tephra.TransactionFailureException; import com.google.common.base.Charsets; import com.google.common.base.Function; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.Maps; import com.google.common.hash.Hashing; import com.google.common.io.ByteStreams; import com.google.common.io.Closeables; import com.google.common.io.Files; import com.google.common.reflect.TypeToken; import org.apache.twill.api.RunId; import org.apache.twill.common.Cancellable; import org.apache.twill.filesystem.Location; import org.junit.Assert; import org.junit.ClassRule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.URL; import java.net.URLConnection; import java.nio.ByteBuffer; import java.nio.channels.WritableByteChannel; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.annotation.Nullable; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; /** * */ public class HttpHandlerGeneratorTest { @ClassRule public static final TemporaryFolder TEMP_FOLDER = new TemporaryFolder(); @Path("/p1") public abstract static class BaseHttpHandler extends AbstractHttpServiceHandler { @GET @Path("/handle") public void process(HttpServiceRequest request, HttpServiceResponder responder) { responder.sendString("Hello World"); } } @Path("/p2") public static final class MyHttpHandler extends BaseHttpHandler { @Override public void initialize(HttpServiceContext context) throws Exception { super.initialize(context); } @Path("/echo/{name}") @POST public void echo(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("name") String name) { responder.sendString(Charsets.UTF_8.decode(request.getContent()).toString() + " " + name); } @Path("/echo/firstHeaders") @GET public void echoFirstHeaders(HttpServiceRequest request, HttpServiceResponder responder) { Map<String, List<String>> headers = request.getAllHeaders(); responder.sendStatus(200, Maps.transformValues(headers, new Function<List<String>, String>() { @Override public String apply(List<String> input) { return input.iterator().next(); } })); } @Path("/echo/allHeaders") @GET public void echoAllHeaders(HttpServiceRequest request, HttpServiceResponder responder) { List<Map.Entry<String, String>> headers = new ArrayList<>(); for (Map.Entry<String, List<String>> entry : request.getAllHeaders().entrySet()) { for (String value : entry.getValue()) { headers.add(Maps.immutableEntry(entry.getKey(), value)); } } responder.sendStatus(200, headers); } } // Omit class-level PATH annotation, to verify that prefix is still prepended to handled path. public static final class NoAnnotationHandler extends AbstractHttpServiceHandler { @Path("/ping") @GET public void echo(HttpServiceRequest request, HttpServiceResponder responder) { responder.sendString("OK"); } } /** * A testing handler for testing file upload and download through usage of {@link HttpContentConsumer} * and {@link HttpContentProducer}. */ public static final class FileHandler extends AbstractHttpServiceHandler { private final File outputDir; public FileHandler(File outputDir) { this.outputDir = outputDir; } @Path("/upload/{file}") @PUT public HttpContentConsumer upload(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("file") String file) throws IOException { return new FileContentConsumer(new File(outputDir, file)); } @Path("/download/{file}") @GET public void download(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("file") String file) { Location location = Locations.toLocation(new File(outputDir, file)); try { responder.send(200, location, "text/plain"); } catch (IOException e) { responder.sendStatus(500); } } // A POST endpoint for upload file and response with the file content using HttpContentProducer @Path("/upload/{file}") @POST public HttpContentConsumer uploadDownload(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("file") String file) throws IOException { final File targetFile = new File(outputDir, file); return new FileContentConsumer(targetFile) { @Override protected void response(HttpServiceResponder responder) throws IOException { responder.send(200, Locations.toLocation(targetFile), "text/plain"); } }; } } /** * A {@link HttpContentConsumer} that writes uploaded bytes to a file. */ private static class FileContentConsumer extends HttpContentConsumer { private static final Logger LOG = LoggerFactory.getLogger(FileContentConsumer.class); private final WritableByteChannel channel; FileContentConsumer(File file) throws IOException { this.channel = new FileOutputStream(file).getChannel(); } @Override public void onReceived(ByteBuffer chunk, Transactional transactional) throws Exception { channel.write(chunk); } @Override public void onFinish(HttpServiceResponder responder) throws Exception { channel.close(); response(responder); } @Override public void onError(HttpServiceResponder responder, Throwable failureCause) { Closeables.closeQuietly(channel); LOG.error("Failed when handling upload", failureCause); } protected void response(HttpServiceResponder responder) throws IOException { responder.sendStatus(200); } } @Test public void testHttpHeaders() throws Exception { MetricsContext noOpsMetricsContext = new NoOpMetricsCollectionService().getContext(new HashMap<String, String>()); HttpHandlerFactory factory = new HttpHandlerFactory("/prefix", noOpsMetricsContext); HttpHandler httpHandler = factory.createHttpHandler( TypeToken.of(MyHttpHandler.class), new AbstractDelegatorContext<MyHttpHandler>() { @Override protected MyHttpHandler createHandler() { return new MyHttpHandler(); } }); NettyHttpService service = NettyHttpService.builder() .addHttpHandlers(ImmutableList.of(httpHandler)) .build(); service.startAndWait(); try { InetSocketAddress bindAddress = service.getBindAddress(); // Make a request with headers that the response should carry first value for each header name HttpURLConnection urlConn = (HttpURLConnection) new URL(String.format("http://%s:%d/prefix/p2/echo/firstHeaders", bindAddress.getHostName(), bindAddress.getPort())).openConnection(); urlConn.addRequestProperty("k1", "v1"); urlConn.addRequestProperty("k1", "v2"); urlConn.addRequestProperty("k2", "v2"); Assert.assertEquals(200, urlConn.getResponseCode()); Map<String, List<String>> headers = urlConn.getHeaderFields(); Assert.assertEquals(ImmutableList.of("v1"), headers.get("k1")); Assert.assertEquals(ImmutableList.of("v2"), headers.get("k2")); // Make a request with headers that the response should carry all values for each header name urlConn = (HttpURLConnection) new URL(String.format("http://%s:%d/prefix/p2/echo/allHeaders", bindAddress.getHostName(), bindAddress.getPort())).openConnection(); urlConn.addRequestProperty("k1", "v1"); urlConn.addRequestProperty("k1", "v2"); urlConn.addRequestProperty("k1", "v3"); urlConn.addRequestProperty("k2", "v2"); Assert.assertEquals(200, urlConn.getResponseCode()); headers = urlConn.getHeaderFields(); // URLConnection always reverse the ordering of the header values. Assert.assertEquals(ImmutableList.of("v3", "v2", "v1"), headers.get("k1")); Assert.assertEquals(ImmutableList.of("v2"), headers.get("k2")); } finally { service.stopAndWait(); } } @Test public void testContentConsumer() throws Exception { MetricsContext noOpsMetricsContext = new NoOpMetricsCollectionService().getContext(new HashMap<String, String>()); HttpHandlerFactory factory = new HttpHandlerFactory("/content", noOpsMetricsContext); // Create the file upload handler and starts a netty server with it final File outputDir = TEMP_FOLDER.newFolder(); HttpHandler httpHandler = factory.createHttpHandler( TypeToken.of(FileHandler.class), new AbstractDelegatorContext<FileHandler>() { @Override protected FileHandler createHandler() { return new FileHandler(outputDir); } }); // Creates a Netty http server with 1K request buffer NettyHttpService service = NettyHttpService.builder() .addHttpHandlers(ImmutableList.of(httpHandler)) .setHttpChunkLimit(1024) .build(); service.startAndWait(); try { InetSocketAddress bindAddress = service.getBindAddress(); // Make a PUT call HttpURLConnection urlConn = (HttpURLConnection) new URL( String.format("http://%s:%d/content/upload/test.txt", bindAddress.getHostName(), bindAddress.getPort())).openConnection(); try { urlConn.setReadTimeout(2000); urlConn.setDoOutput(true); urlConn.setRequestMethod("PUT"); // Set to use default chunk size urlConn.setChunkedStreamingMode(-1); // Write over 1MB of data // One fragment is 10K of data byte[] fragment = Strings.repeat("0123456789", 1024).getBytes(Charsets.UTF_8); File localFile = TEMP_FOLDER.newFile(); try ( OutputStream os = urlConn.getOutputStream(); FileOutputStream fos = new FileOutputStream(localFile) ) { for (int i = 0; i < 100; i++) { os.write(fragment); fos.write(fragment); } } Assert.assertEquals(200, urlConn.getResponseCode()); // File written on the remote end should be > 1K and should be the same as the local one File remoteFile = new File(outputDir, "test.txt"); Assert.assertTrue(remoteFile.length() > 1024); Assert.assertEquals(Files.hash(localFile, Hashing.md5()), Files.hash(remoteFile, Hashing.md5())); } finally { urlConn.disconnect(); } } finally { service.stopAndWait(); } } @Test public void testContentProducer() throws Exception { MetricsContext noOpsMetricsContext = new NoOpMetricsCollectionService().getContext(new HashMap<String, String>()); HttpHandlerFactory factory = new HttpHandlerFactory("/content", noOpsMetricsContext); // Create the file upload handler and starts a netty server with it final File outputDir = TEMP_FOLDER.newFolder(); HttpHandler httpHandler = factory.createHttpHandler( TypeToken.of(FileHandler.class), new AbstractDelegatorContext<FileHandler>() { @Override protected FileHandler createHandler() { return new FileHandler(outputDir); } }); NettyHttpService service = NettyHttpService.builder() .addHttpHandlers(ImmutableList.of(httpHandler)) .build(); service.startAndWait(); try { // Generate a 100K file File file = TEMP_FOLDER.newFile(); Files.write(Strings.repeat("0123456789", 10240).getBytes(Charsets.UTF_8), file); InetSocketAddress bindAddress = service.getBindAddress(); // Upload the generated file URL uploadURL = new URL(String.format("http://%s:%d/content/upload/test.txt", bindAddress.getHostName(), bindAddress.getPort())); HttpURLConnection urlConn = (HttpURLConnection) uploadURL.openConnection(); try { urlConn.setDoOutput(true); urlConn.setRequestMethod("PUT"); Files.copy(file, urlConn.getOutputStream()); Assert.assertEquals(200, urlConn.getResponseCode()); } finally { urlConn.disconnect(); } // Download the file File downloadFile = TEMP_FOLDER.newFile(); urlConn = (HttpURLConnection) new URL( String.format("http://%s:%d/content/download/test.txt", bindAddress.getHostName(), bindAddress.getPort())).openConnection(); try { ByteStreams.copy(urlConn.getInputStream(), Files.newOutputStreamSupplier(downloadFile)); } finally { urlConn.disconnect(); } // Compare if the file content are the same Assert.assertTrue(Files.equal(file, downloadFile)); // Download a file that doesn't exist urlConn = (HttpURLConnection) new URL( String.format("http://%s:%d/content/download/test2.txt", bindAddress.getHostName(), bindAddress.getPort())).openConnection(); try { Assert.assertEquals(500, urlConn.getResponseCode()); } finally { urlConn.disconnect(); } // Upload the file to the POST endpoint. The endpoint should response with the same file content downloadFile = TEMP_FOLDER.newFile(); urlConn = (HttpURLConnection) uploadURL.openConnection(); try { urlConn.setDoOutput(true); urlConn.setRequestMethod("POST"); Files.copy(file, urlConn.getOutputStream()); ByteStreams.copy(urlConn.getInputStream(), Files.newOutputStreamSupplier(downloadFile)); Assert.assertEquals(200, urlConn.getResponseCode()); Assert.assertTrue(Files.equal(file, downloadFile)); } finally { urlConn.disconnect(); } } finally { service.stopAndWait(); } } @Test public void testHttpHandlerGenerator() throws Exception { MetricsContext noOpsMetricsContext = new NoOpMetricsCollectionService().getContext(new HashMap<String, String>()); HttpHandlerFactory factory = new HttpHandlerFactory("/prefix", noOpsMetricsContext); HttpHandler httpHandler = factory.createHttpHandler( TypeToken.of(MyHttpHandler.class), new AbstractDelegatorContext<MyHttpHandler>() { @Override protected MyHttpHandler createHandler() { return new MyHttpHandler(); } }); HttpHandler httpHandlerWithoutAnnotation = factory.createHttpHandler( TypeToken.of(NoAnnotationHandler.class), new AbstractDelegatorContext<NoAnnotationHandler>() { @Override protected NoAnnotationHandler createHandler() { return new NoAnnotationHandler(); } }); NettyHttpService service = NettyHttpService.builder() .addHttpHandlers(ImmutableList.of(httpHandler, httpHandlerWithoutAnnotation)) .build(); service.startAndWait(); try { InetSocketAddress bindAddress = service.getBindAddress(); // Make a GET call URLConnection urlConn = new URL(String.format("http://%s:%d/prefix/p2/handle", bindAddress.getHostName(), bindAddress.getPort())).openConnection(); urlConn.setReadTimeout(2000); Assert.assertEquals("Hello World", new String(ByteStreams.toByteArray(urlConn.getInputStream()), Charsets.UTF_8)); // Make a POST call urlConn = new URL(String.format("http://%s:%d/prefix/p2/echo/test", bindAddress.getHostName(), bindAddress.getPort())).openConnection(); urlConn.setReadTimeout(2000); urlConn.setDoOutput(true); ByteStreams.copy(ByteStreams.newInputStreamSupplier("Hello".getBytes(Charsets.UTF_8)), urlConn.getOutputStream()); Assert.assertEquals("Hello test", new String(ByteStreams.toByteArray(urlConn.getInputStream()), Charsets.UTF_8)); // Ensure that even though the handler did not have a class-level annotation, we still prefix the path that it // handles by "/prefix" urlConn = new URL(String.format("http://%s:%d/prefix/ping", bindAddress.getHostName(), bindAddress.getPort())) .openConnection(); urlConn.setReadTimeout(2000); Assert.assertEquals("OK", new String(ByteStreams.toByteArray(urlConn.getInputStream()), Charsets.UTF_8)); } finally { service.stopAndWait(); } } private abstract static class AbstractDelegatorContext<T extends HttpServiceHandler> implements DelegatorContext<T> { private final ThreadLocal<T> threadLocal = new ThreadLocal<T>() { @Override protected T initialValue() { return createHandler(); } }; @Override public final T getHandler() { return threadLocal.get(); } @Override public final HttpServiceContext getServiceContext() { return new NoOpHttpServiceContext(); } @Override public Cancellable capture() { threadLocal.remove(); return new Cancellable() { @Override public void cancel() { // no-op } }; } protected abstract T createHandler(); } /** * An no-op implementation of {@link HttpServiceContext} that implements no-op transactional operations. */ private static class NoOpHttpServiceContext implements TransactionalHttpServiceContext { @Override public HttpServiceHandlerSpecification getSpecification() { return null; } @Override public int getInstanceCount() { return 1; } @Override public int getInstanceId() { return 1; } @Override public ApplicationSpecification getApplicationSpecification() { return null; } @Override public Map<String, String> getRuntimeArguments() { return null; } @Override public String getNamespace() { return null; } @Override public RunId getRunId() { return null; } @Override public <T extends Dataset> T getDataset(String name) throws DatasetInstantiationException { return getDataset(name, null); } @Override public <T extends Dataset> T getDataset(String name, Map<String, String> arguments) throws DatasetInstantiationException { throw new DatasetInstantiationException( String.format("Dataset '%s' cannot be instantiated. Operation not supported", name)); } @Override public void releaseDataset(Dataset dataset) { // no-op } @Override public void discardDataset(Dataset dataset) { // nop-op } @Override public TransactionContext newTransactionContext() { return new TransactionContext(null, ImmutableList.<TransactionAware>of()) { @Override public boolean addTransactionAware(TransactionAware txAware) { return false; } @Override public void start() throws TransactionFailureException { } @Override public void finish() throws TransactionFailureException { } @Override public void abort() throws TransactionFailureException { } @Override public void abort(TransactionFailureException cause) throws TransactionFailureException { } }; } @Override public void dismissTransactionContext() { // no-op } @Override public URL getServiceURL(String applicationId, String serviceId) { return null; } @Override public URL getServiceURL(String serviceId) { return null; } @Override public PluginProperties getPluginProperties(String pluginId) { return null; } @Override public <T> Class<T> loadPluginClass(String pluginId) { return null; } @Override public <T> T newPluginInstance(String pluginId) throws InstantiationException { return null; } @Override public Admin getAdmin() { return new Admin() { @Override public boolean datasetExists(String name) { return false; } @Nullable @Override public String getDatasetType(String name) throws InstanceNotFoundException { throw new InstanceNotFoundException(name); } @Nullable @Override public DatasetProperties getDatasetProperties(String name) throws InstanceNotFoundException { throw new InstanceNotFoundException(name); } @Override public void createDataset(String name, String type, DatasetProperties properties) { // nop-op } @Override public void updateDataset(String name, DatasetProperties properties) { // no-op } @Override public void dropDataset(String name) { // no-op } @Override public void truncateDataset(String name) { // nop-op } }; } } }