diff --git a/build.gradle b/build.gradle index 770c0c4b67..7676db1651 100644 --- a/build.gradle +++ b/build.gradle @@ -202,6 +202,11 @@ project(":core") { compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.4' compile group: 'com.google.guava', name: 'guava', version: '19.0' compile group: 'com.google.auto.value', name: 'auto-value', version: '1.1' + compile group: 'com.google.code.gson', name: 'gson', version: '2.6.2' + compile group: 'org.eclipse.jetty', name:'jetty-server', version:'9.3.8.v20160314' + testCompile group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.2' + testCompile group: 'org.apache.httpcomponents', name: 'httpcore', version: '4.2.2' + testCompile group: 'org.apache.httpcomponents', name: 'httpmime', version: '4.5.2' // We use the no_aop version of Guice because the aop isn't avaiable in arm java // http://stackoverflow.com/a/15235190/3708426 // https://github.com/google/guice/wiki/OptionalAOP diff --git a/core/src/main/java/edu/wpi/grip/core/GripCoreModule.java b/core/src/main/java/edu/wpi/grip/core/GripCoreModule.java index fda2aacc32..f9f874cebb 100644 --- a/core/src/main/java/edu/wpi/grip/core/GripCoreModule.java +++ b/core/src/main/java/edu/wpi/grip/core/GripCoreModule.java @@ -8,6 +8,7 @@ import edu.wpi.grip.core.sockets.OutputSocket; import edu.wpi.grip.core.sockets.OutputSocketImpl; import edu.wpi.grip.core.sources.CameraSource; +import edu.wpi.grip.core.sources.HttpSource; import edu.wpi.grip.core.sources.ImageFileSource; import edu.wpi.grip.core.sources.MultiImageFileSource; import edu.wpi.grip.core.util.ExceptionWitness; @@ -137,6 +138,9 @@ public void hear(TypeLiteral type, TypeEncounter encounter) { install(new FactoryModuleBuilder() .implement(MultiImageFileSource.class, MultiImageFileSource.class) .build(MultiImageFileSource.Factory.class)); + install(new FactoryModuleBuilder() + .implement(HttpSource.class, HttpSource.class) + .build(HttpSource.Factory.class)); install(new FactoryModuleBuilder().build(ExceptionWitness.Factory.class)); } diff --git a/core/src/main/java/edu/wpi/grip/core/Main.java b/core/src/main/java/edu/wpi/grip/core/Main.java index 544b5bd6be..21dfa126ec 100644 --- a/core/src/main/java/edu/wpi/grip/core/Main.java +++ b/core/src/main/java/edu/wpi/grip/core/Main.java @@ -2,6 +2,8 @@ import edu.wpi.grip.core.events.ExceptionClearedEvent; import edu.wpi.grip.core.events.ExceptionEvent; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.HttpPipelineSwitcher; import edu.wpi.grip.core.operations.CVOperations; import edu.wpi.grip.core.operations.Operations; import edu.wpi.grip.core.operations.network.GripNetworkModule; @@ -12,6 +14,7 @@ import com.google.common.eventbus.Subscribe; import com.google.inject.Guice; import com.google.inject.Injector; +import com.google.inject.util.Modules; import java.io.File; import java.io.IOException; @@ -37,30 +40,35 @@ public class Main { private CVOperations cvOperations; @Inject private Logger logger; + @Inject + private GripServer gripServer; + @Inject + private HttpPipelineSwitcher pipelineSwitcher; @SuppressWarnings({"PMD.SystemPrintln", "JavadocMethod"}) public static void main(String[] args) throws IOException, InterruptedException { - final Injector injector = Guice.createInjector(new GripCoreModule(), new GripNetworkModule(), - new GripSourcesHardwareModule()); + final Injector injector = Guice.createInjector(Modules.override( + new GripCoreModule(), new GripSourcesHardwareModule()).with(new GripNetworkModule())); injector.getInstance(Main.class).start(args); } @SuppressWarnings({"PMD.SystemPrintln", "JavadocMethod"}) public void start(String[] args) throws IOException, InterruptedException { - if (args.length != 1) { - System.err.println("Usage: GRIP.jar project.grip"); - return; - } else { + String projectPath = null; + if (args.length == 1) { logger.log(Level.INFO, "Loading file " + args[0]); + projectPath = args[0]; } operations.addOperations(); cvOperations.addOperations(); - - final String projectPath = args[0]; + gripServer.addHandler(pipelineSwitcher); + gripServer.start(); // Open a project from a .grip file specified on the command line - project.open(new File(projectPath)); + if (projectPath != null) { + project.open(new File(projectPath)); + } pipelineRunner.startAsync(); @@ -88,4 +96,4 @@ public final void onExceptionClearedEvent(ExceptionClearedEvent event) { + "Event"); } -} +} \ No newline at end of file diff --git a/core/src/main/java/edu/wpi/grip/core/PipelineRunner.java b/core/src/main/java/edu/wpi/grip/core/PipelineRunner.java index ba7ee42896..77630f3f45 100644 --- a/core/src/main/java/edu/wpi/grip/core/PipelineRunner.java +++ b/core/src/main/java/edu/wpi/grip/core/PipelineRunner.java @@ -3,6 +3,8 @@ import edu.wpi.grip.core.events.RenderEvent; import edu.wpi.grip.core.events.RunPipelineEvent; +import edu.wpi.grip.core.events.RunStartedEvent; +import edu.wpi.grip.core.events.RunStoppedEvent; import edu.wpi.grip.core.events.StopPipelineEvent; import edu.wpi.grip.core.util.SinglePermitSemaphore; import edu.wpi.grip.core.util.service.AutoRestartingService; @@ -28,6 +30,8 @@ import javax.annotation.Nullable; import javax.inject.Inject; +import static com.google.common.base.Preconditions.checkNotNull; + /** * Runs the pipeline in a separate thread. The runner listens for {@link RunPipelineEvent * RunPipelineEvents} and releases the pipeline thread to update the sources and run the steps. @@ -68,6 +72,7 @@ protected void runOneIteration() throws InterruptedException { } pipelineFlag.acquire(); + eventBus.post(new RunStartedEvent()); if (!super.isRunning()) { return; @@ -77,6 +82,7 @@ protected void runOneIteration() throws InterruptedException { if (super.isRunning()) { eventBus.post(new RenderEvent()); } + eventBus.post(new RunStoppedEvent()); } @Override @@ -196,6 +202,7 @@ private void runPipeline(Supplier isRunning) { @Subscribe @AllowConcurrentEvents public void onRunPipeline(RunPipelineEvent event) { + checkNotNull(event); if (event.pipelineShouldRun()) { pipelineFlag.release(); } diff --git a/core/src/main/java/edu/wpi/grip/core/Source.java b/core/src/main/java/edu/wpi/grip/core/Source.java index 18e4a34ea7..0e20b00bce 100644 --- a/core/src/main/java/edu/wpi/grip/core/Source.java +++ b/core/src/main/java/edu/wpi/grip/core/Source.java @@ -2,6 +2,7 @@ import edu.wpi.grip.core.sockets.OutputSocket; import edu.wpi.grip.core.sources.CameraSource; +import edu.wpi.grip.core.sources.HttpSource; import edu.wpi.grip.core.sources.ImageFileSource; import edu.wpi.grip.core.sources.MultiImageFileSource; import edu.wpi.grip.core.util.ExceptionWitness; @@ -108,6 +109,8 @@ public static class SourceFactoryImpl implements SourceFactory { ImageFileSource.Factory imageFactory; @Inject MultiImageFileSource.Factory multiImageFactory; + @Inject + HttpSource.Factory httpFactory; @Override public Source create(Class type, Properties properties) throws IOException { @@ -117,6 +120,8 @@ public Source create(Class type, Properties properties) throws IOException { return imageFactory.create(properties); } else if (type.isAssignableFrom(MultiImageFileSource.class)) { return multiImageFactory.create(properties); + } else if (type.isAssignableFrom(HttpSource.class)) { + return httpFactory.create(properties); } else { throw new IllegalArgumentException(type + " was not a valid type"); } diff --git a/core/src/main/java/edu/wpi/grip/core/events/RunStartedEvent.java b/core/src/main/java/edu/wpi/grip/core/events/RunStartedEvent.java new file mode 100644 index 0000000000..6e323d1ac6 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/events/RunStartedEvent.java @@ -0,0 +1,8 @@ +package edu.wpi.grip.core.events; + +/** + * An event fired when the pipeline starts running. This is guaranteed to be followed by a + * corresponding {@link RunStoppedEvent}. + */ +public class RunStartedEvent { +} diff --git a/core/src/main/java/edu/wpi/grip/core/events/RunStoppedEvent.java b/core/src/main/java/edu/wpi/grip/core/events/RunStoppedEvent.java new file mode 100644 index 0000000000..f80079c4b6 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/events/RunStoppedEvent.java @@ -0,0 +1,11 @@ +package edu.wpi.grip.core.events; + +/** + * An event fired when the pipeline stops running. This is guaranteed to follow a corresponding + * {@link RunStartedEvent}. + * + *

This is different from {@link RenderEvent} in that it will always be fired when the + * pipeline runs. + */ +public class RunStoppedEvent { +} diff --git a/core/src/main/java/edu/wpi/grip/core/exception/GripException.java b/core/src/main/java/edu/wpi/grip/core/exception/GripException.java new file mode 100644 index 0000000000..081572a2f5 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/exception/GripException.java @@ -0,0 +1,19 @@ + +package edu.wpi.grip.core.exception; + +/** + * An exception thrown when something goes wrong with an internal GRIP + * operation. This class is {@code abstract} to encourage making subclasses + * for specific cases. + */ +public abstract class GripException extends RuntimeException { + + public GripException(String message) { + super(message); + } + + public GripException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/core/src/main/java/edu/wpi/grip/core/exception/GripServerException.java b/core/src/main/java/edu/wpi/grip/core/exception/GripServerException.java new file mode 100644 index 0000000000..cd54d09c83 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/exception/GripServerException.java @@ -0,0 +1,16 @@ +package edu.wpi.grip.core.exception; + +/** + * An exception thrown when something goes wrong in the + * {@link edu.wpi.grip.core.http.GripServer GripServer}. + */ +public class GripServerException extends GripException { + + public GripServerException(String message) { + super(message); + } + + public GripServerException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/http/ContextStore.java b/core/src/main/java/edu/wpi/grip/core/http/ContextStore.java new file mode 100644 index 0000000000..48c7923c8f --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/http/ContextStore.java @@ -0,0 +1,58 @@ +package edu.wpi.grip.core.http; + +import com.google.inject.Singleton; + +import java.util.HashSet; +import java.util.Set; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Keeps a record of contexts claimed by HTTP request handlers. + */ +@Singleton +public class ContextStore { + + private final Set store = new HashSet<>(); + + /** + * Records the given context. + * + * @param context the context to record. This cannot be null. + * @throws IllegalArgumentException if the given context has already been claimed + */ + public void record(@Nonnull String context) throws IllegalArgumentException { + checkNotNull(context); + if (!store.add(context)) { + throw new IllegalArgumentException("Context is already claimed: " + context); + } + } + + /** + * Erases the given context from this store, if it's present. If {@code context} is {@code null}, + * this will do nothing and return {@code false}. + * + * @param context the context to erase + * @return true if the context was erased, false if it wasn't erased + * or if it wasn't present to begin with. + */ + public boolean erase(@Nullable String context) { + return store.remove(context); + } + + /** + * Checks if the given context has been recorded in this store. + * If {@code context} is {@code null}, this will return {@code false}. + * + * @param context the context to check + * @return true if the given context has been recorded in this store + */ + public boolean contains(@Nullable String context) { + return store.contains(context); + } + + +} diff --git a/core/src/main/java/edu/wpi/grip/core/http/GenericHandler.java b/core/src/main/java/edu/wpi/grip/core/http/GenericHandler.java new file mode 100644 index 0000000000..0a4fe3bcb8 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/http/GenericHandler.java @@ -0,0 +1,131 @@ +package edu.wpi.grip.core.http; + +import org.eclipse.jetty.server.handler.AbstractHandler; + +import java.io.IOException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Generic Jetty handler. + * + *

Instances of this class can either claim a context, preventing other instances from handling + * events on that context, or not, in which case it will run on any context. + */ +public abstract class GenericHandler extends AbstractHandler { + + private final ContextStore contextStore; + + /** + * The context that this handles. + */ + protected final String context; + + /** + * HTTP content type for json. + */ + public static final String CONTENT_TYPE_JSON = "application/json"; + + /** + * HTTP content type for HTML. + */ + public static final String CONTENT_TYPE_HTML = "text/html"; + + /** + * HTTP content type for plain text. + */ + public static final String CONTENT_TYPE_PLAIN_TEXT = "text/plain"; + + /** + * Creates a generic handler for all contexts on the server. + */ + protected GenericHandler() { + super(); + contextStore = new ContextStore(); + context = null; + } + + /** + * Creates a generic handler that handles requests for the given context. + * That context will not be claimed. + * + *

Note that the context is case sensitive. + * + * @param store the context store to use to check for claimed contexts + * @param context the context for this handler + * @throws IllegalArgumentException if the given context has already been claimed + */ + protected GenericHandler(ContextStore store, String context) { + this(store, context, false); + } + + /** + * Creates a generic handler that handles requests for the given context. + *

+ * Note that the context is case sensitive. + *

+ * + * @param store the context store to use to check for claimed contexts + * @param context the context for this handler + * @param doClaim flag marking if the given context should be claimed + * @throws IllegalArgumentException if the given context has already been claimed + */ + protected GenericHandler(ContextStore store, String context, boolean doClaim) { + super(); + checkNotNull(context); + if (doClaim) { + store.record(context); + } + this.contextStore = store; + this.context = context; + } + + /** + * Releases the context that this handles, allowing it to be claimed by another handler. + */ + protected void releaseContext() { + contextStore.erase(context); + } + + /** + * Gets the context for this handler. + */ + public String getContext() { + return context; + } + + // Static helper methods + + /** + * Sends text content to the client. + * + * @param response the response that will be sent to the client + * @param content the content to send + * @param contentType the type of the content (e.g. "application/json", "text/html") + * @throws IOException if content couldn't be written to the response + */ + protected static void sendTextContent(HttpServletResponse response, + String content, + String contentType) throws IOException { + response.setContentType(contentType); + response.getWriter().print(content); + } + + /** + * Checks if the given request is a POST. + */ + protected static boolean isPost(HttpServletRequest request) { + return request.getMethod().equals("POST"); + } + + /** + * Checks if the given request is a GET. + */ + protected static boolean isGet(HttpServletRequest request) { + return request.getMethod().equals("GET"); + } + +} diff --git a/core/src/main/java/edu/wpi/grip/core/http/GripServer.java b/core/src/main/java/edu/wpi/grip/core/http/GripServer.java new file mode 100644 index 0000000000..2a7fb4be11 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/http/GripServer.java @@ -0,0 +1,238 @@ +package edu.wpi.grip.core.http; + +import edu.wpi.grip.core.events.ProjectSettingsChangedEvent; +import edu.wpi.grip.core.exception.GripServerException; +import edu.wpi.grip.core.settings.SettingsProvider; + +import com.google.common.eventbus.Subscribe; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.HandlerCollection; + +/** + * An internal HTTP server. + */ +@Singleton +public class GripServer { + + /** + * Factory for creating a new Jetty server. + */ + private final JettyServerFactory serverFactory; + + /** + * The port that this server is running on. + */ + private int port; + + /** + * The internal Jetty server that actually handles all server operations. + */ + private Server server; + + /** + * A collection of Jetty handlers that can be added to or removed during runtime. + */ + private final HandlerCollection handlers = new HandlerCollection(true); + + /** + * The current lifecycle state of the server. + */ + private State state = State.PRE_RUN; + + /** + * Possible lifecycle states of the server. + */ + public enum State { + /** The server has not been started yet. */ + PRE_RUN, + /** The server is currently running. */ + RUNNING, + /** The server was running and has been stopped. */ + STOPPED + } + + /** + * The root path for all GRIP-related HTTP activity. + */ + public static final String ROOT_PATH = "/GRIP"; + + /** + * The root path for uploading data to the server. + */ + public static final String UPLOAD_PATH = ROOT_PATH + "/upload"; + + /** + * The path for uploading images. To upload an image, post an HTTP event to + * {@code /GRIP/upload/image}, with the image bytes as the data. + */ + public static final String IMAGE_UPLOAD_PATH = UPLOAD_PATH + "/image"; + + /** + * The path for setting which pipeline to run. To set the pipeline, post an + * HTTP event to {@code /GRIP/upload/pipeline}, + * with the content of the pipeline save file as the data. + */ + public static final String PIPELINE_UPLOAD_PATH = UPLOAD_PATH + "/pipeline"; + + /** + * The path for requesting data. Data will be returned as a json-formatted + * map of the outputs of all requested data sets. + * + *

For example, performing a {@code GET} request on the path + * {@code /GRIP/data?foo&bar} will return a map such as + *
+ *

+   * {
+   *   'foo': {
+   *        // data
+   *      },
+   *   'bar':
+   *     {
+   *       // data
+   *     }
+   * }
+   * 
+ */ + public static final String DATA_PATH = ROOT_PATH + "/data"; + + public interface JettyServerFactory { + Server create(int port); + } + + public static class JettyServerFactoryImpl implements JettyServerFactory { + + @Override + public Server create(int port) { + return new Server(port); + } + } + + @Inject + GripServer(ContextStore contextStore, + JettyServerFactory serverFactory, + SettingsProvider settingsProvider) { + this.port = settingsProvider.getProjectSettings().getServerPort(); + this.serverFactory = serverFactory; + this.server = serverFactory.create(port); + this.server.setHandler(handlers); + handlers.addHandler(new NoContextHandler(contextStore)); + + Runtime.getRuntime().addShutdownHook(new Thread(this::stop)); + } + + /** + * Adds the given handler to the server. Does nothing if the server already has that handler. + * + * @param handler the handler to add + */ + public void addHandler(Handler handler) { + if (!handlers.contains(handler)) { + handlers.addHandler(handler); + } + } + + /** + * Removes the given handler from the server. + * Does nothing if the server does not have that handler. + * + * @param handler the handler to remove + */ + public void removeHandler(Handler handler) { + handlers.removeHandler(handler); + } + + /** + * Starts this server. + * Has no effect if the server has already been started or if it's been stopped. + */ + public void start() { + if (state == State.PRE_RUN) { + try { + server.start(); + } catch (Exception ex) { + throw new GripServerException("Could not start Jetty server", ex); + } + state = State.RUNNING; + } + } + + /** + * Stops this server. Note that a shutdown hook has been registered to call + * this method, so it's unlikely that this should need to be called. If you + * need to restart the server, use {@link #restart()} as this method will kill the + * internal HTTP server, which cannot be restarted by {@link #start()}. + */ + public void stop() { + if (state == State.RUNNING) { + try { + server.stop(); + } catch (Exception ex) { + throw new GripServerException("Could not stop Jetty server", ex); + } + state = State.STOPPED; + } + } + + /** + * Restarts the server on the current port. + * + * @throws GripServerException if the server was unable to be restarted. + */ + public void restart() { + try { + if (state == State.RUNNING) { + try { + server.stop(); + } catch (Exception ex) { + throw new GripServerException("Could not stop Jetty server", ex); + } + state = State.STOPPED; + } + server = serverFactory.create(port); + start(); + } catch (GripServerException | IllegalStateException ex) { + throw new GripServerException("Could not restart GripServer", ex); + } + } + + /** + * Gets the current state of the server. + */ + public State getState() { + return state; + } + + /** + * Stops the server (if it's running) and creates a new HTTP server on the given port. + * + * @param port the new port to run on. + */ + private void setPort(int port) { + stop(); + server = serverFactory.create(port); + state = State.PRE_RUN; + } + + /** + * Gets the port this server is running on. + */ + public int getPort() { + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + @Subscribe + public void settingsChanged(ProjectSettingsChangedEvent event) { + int port = event.getProjectSettings().getServerPort(); + if (port != getPort()) { + setPort(port); + server.setHandler(handlers); + start(); + } + } + +} diff --git a/core/src/main/java/edu/wpi/grip/core/http/HttpPipelineSwitcher.java b/core/src/main/java/edu/wpi/grip/core/http/HttpPipelineSwitcher.java new file mode 100644 index 0000000000..d349427193 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/http/HttpPipelineSwitcher.java @@ -0,0 +1,64 @@ +package edu.wpi.grip.core.http; + +import edu.wpi.grip.core.serialization.Project; +import edu.wpi.grip.core.util.GripMode; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import org.apache.commons.io.IOUtils; +import org.eclipse.jetty.server.Request; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Jetty handler responsible for loading pipelines sent over HTTP. + */ +@Singleton +public class HttpPipelineSwitcher extends PedanticHandler { + + private final Project project; + private final GripMode mode; + + @Inject + HttpPipelineSwitcher(ContextStore store, Project project, GripMode mode) { + super(store, GripServer.PIPELINE_UPLOAD_PATH, true); + this.project = project; + this.mode = mode; + } + + @Override + protected void handleIfPassed(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + if (!isPost(request)) { + response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + baseRequest.setHandled(true); + return; + } + switch (mode) { + case HEADLESS: + project.open(new String(IOUtils.toByteArray(request.getInputStream()), "UTF-8")); + response.setStatus(HttpServletResponse.SC_CREATED); + baseRequest.setHandled(true); + break; + case GUI: + // Don't run in GUI mode, it doesn't make much sense and can easily deadlock if pipelines + // are rapidly posted. + // Intentional fall-through to default + default: + // Don't know the mode or the mode is unsupported; let the client know + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + sendTextContent(response, + String.format("GRIP is not in the correct mode: should be HEADLESS, but is %s", mode), + CONTENT_TYPE_PLAIN_TEXT); + baseRequest.setHandled(true); + break; + } + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/http/NoContextHandler.java b/core/src/main/java/edu/wpi/grip/core/http/NoContextHandler.java new file mode 100644 index 0000000000..20578ea5f9 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/http/NoContextHandler.java @@ -0,0 +1,48 @@ +package edu.wpi.grip.core.http; + +import org.eclipse.jetty.server.Request; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Jetty handler for all contexts that are not explicitly claimed by another handler. + * This will respond to HTTP requests with a {@code 404 Not Found} error and HTML page. + */ +class NoContextHandler extends GenericHandler { + + private final ContextStore store; + + private static final String notFoundMessage = + "

404 - Not Found

There is no context for path: '%s'"; + + /** + * Creates a new {@code NoContextHandler} that handles every context not in the given + * {@code ContextStore}. + * + * @param store Any context not in this {@code ContextStore} will get a + * {@code 404 Not Found} error. + */ + public NoContextHandler(ContextStore store) { + super(); + this.store = store; + } + + @Override + public void handle(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + if (store.contains(target)) { + // Let the appropriate handler handle this + return; + } + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + response.setContentType(CONTENT_TYPE_HTML); + response.getWriter().printf(notFoundMessage, target); + baseRequest.setHandled(true); + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/http/PedanticHandler.java b/core/src/main/java/edu/wpi/grip/core/http/PedanticHandler.java new file mode 100644 index 0000000000..30378a7c08 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/http/PedanticHandler.java @@ -0,0 +1,77 @@ +package edu.wpi.grip.core.http; + +import edu.wpi.grip.core.exception.GripServerException; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.AbstractHandler; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * A handler that will only run if a request is on the same path as its context. + */ +public abstract class PedanticHandler extends GenericHandler { + + /** + * Creates a new handler for the given context. That context will not be claimed. + * + * @param store the {@code ContextStore} to store this context in + * @param context the context for this handler + * @see GenericHandler#GenericHandler(ContextStore, String) + */ + protected PedanticHandler(ContextStore store, String context) { + super(store, context); + } + + /** + * Creates a new handler for the given context. + * + * @param store the {@code ContextStore} to store this context in + * @param context the context for this handler + * @param doClaim if the context should be claimed + * @see GenericHandler#GenericHandler(ContextStore, String, boolean) + */ + protected PedanticHandler(ContextStore store, String context, boolean doClaim) { + super(store, context, doClaim); + } + + @Override + public final void handle(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + if (!this.context.equals(target)) { + return; + } + try { + handleIfPassed(target, baseRequest, request, response); + } catch (RuntimeException ex) { + Logger.getLogger(getClass().getName()) + .log(Level.SEVERE, "Exception when handling HTTP request", ex); + throw new GripServerException("Exception when handling HTTP request", ex); + } + } + + /** + * Handles an HTTP request if the target is the same as the one for this handler. + * + * @param target the target of the HTTP request (e.g. a request on "localhost:8080/foo/bar" + * has a target of "foo/bar") + * @param baseRequest the base HTTP request + * @param request the request after being wrapped or filtered by other handlers + * @param response the HTTP response to send to the client + * @see AbstractHandler#handle(String, Request, HttpServletRequest, HttpServletResponse) + */ + protected abstract void handleIfPassed(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) + throws IOException, ServletException; + +} diff --git a/core/src/main/java/edu/wpi/grip/core/operations/Operations.java b/core/src/main/java/edu/wpi/grip/core/operations/Operations.java index 38dee8a8e8..7e15c29709 100644 --- a/core/src/main/java/edu/wpi/grip/core/operations/Operations.java +++ b/core/src/main/java/edu/wpi/grip/core/operations/Operations.java @@ -29,6 +29,7 @@ import edu.wpi.grip.core.operations.network.MapNetworkPublisherFactory; import edu.wpi.grip.core.operations.network.NumberPublishable; import edu.wpi.grip.core.operations.network.Vector2D; +import edu.wpi.grip.core.operations.network.http.HttpPublishOperation; import edu.wpi.grip.core.operations.network.networktables.NTPublishAnnotatedOperation; import edu.wpi.grip.core.operations.network.ros.JavaToMessageConverter; import edu.wpi.grip.core.operations.network.ros.ROSNetworkPublisherFactory; @@ -62,11 +63,13 @@ public class Operations { @Inject Operations(EventBus eventBus, @Named("ntManager") MapNetworkPublisherFactory ntPublisherFactory, + @Named("httpManager") MapNetworkPublisherFactory httpPublishFactory, @Named("rosManager") ROSNetworkPublisherFactory rosPublishFactory, InputSocket.Factory isf, OutputSocket.Factory osf) { this.eventBus = checkNotNull(eventBus, "EventBus cannot be null"); checkNotNull(ntPublisherFactory, "ntPublisherFactory cannot be null"); + checkNotNull(httpPublishFactory, "httpPublisherFactory cannot be null"); checkNotNull(rosPublishFactory, "rosPublishFactory cannot be null"); this.operations = ImmutableList.of( // Composite operations @@ -156,7 +159,27 @@ public class Operations { JavaToMessageConverter.BLOBS)), new OperationMetaData(ROSPublishOperation.descriptionFor(LinesReport.class), () -> new ROSPublishOperation<>(isf, LinesReport.class, rosPublishFactory, - JavaToMessageConverter.LINES)) + JavaToMessageConverter.LINES)), + + // HTTP publishing operations + new OperationMetaData(HttpPublishOperation.descriptionFor(ContoursReport.class), + () -> new HttpPublishOperation<>(isf, ContoursReport.class, httpPublishFactory)), + new OperationMetaData(HttpPublishOperation.descriptionFor(LinesReport.class), + () -> new HttpPublishOperation<>(isf, LinesReport.class, httpPublishFactory)), + new OperationMetaData(HttpPublishOperation.descriptionFor(BlobsReport.class), + () -> new HttpPublishOperation<>(isf, BlobsReport.class, httpPublishFactory)), + new OperationMetaData(HttpPublishOperation.descriptionFor(Size.class), + () -> new HttpPublishOperation<>(isf, Size.class, Vector2D.class, Vector2D::new, + httpPublishFactory)), + new OperationMetaData(HttpPublishOperation.descriptionFor(Point.class), + () -> new HttpPublishOperation<>(isf, Point.class, Vector2D.class, Vector2D::new, + httpPublishFactory)), + new OperationMetaData(HttpPublishOperation.descriptionFor(Number.class), + () -> new HttpPublishOperation<>(isf, Number.class, NumberPublishable.class, + NumberPublishable::new, httpPublishFactory)), + new OperationMetaData(HttpPublishOperation.descriptionFor(Boolean.class), + () -> new HttpPublishOperation<>(isf, Boolean.class, BooleanPublishable.class, + BooleanPublishable::new, httpPublishFactory)) ); } @@ -173,4 +196,4 @@ public void addOperations() { .map(OperationAddedEvent::new) .forEach(eventBus::post); } -} +} \ No newline at end of file diff --git a/core/src/main/java/edu/wpi/grip/core/operations/network/GripNetworkModule.java b/core/src/main/java/edu/wpi/grip/core/operations/network/GripNetworkModule.java index c407acde93..f38cab7ee2 100644 --- a/core/src/main/java/edu/wpi/grip/core/operations/network/GripNetworkModule.java +++ b/core/src/main/java/edu/wpi/grip/core/operations/network/GripNetworkModule.java @@ -1,5 +1,9 @@ package edu.wpi.grip.core.operations.network; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.HttpPipelineSwitcher; +import edu.wpi.grip.core.operations.network.http.DataHandler; +import edu.wpi.grip.core.operations.network.http.HttpPublishManager; import edu.wpi.grip.core.operations.network.networktables.NTManager; import edu.wpi.grip.core.operations.network.ros.ROSManager; import edu.wpi.grip.core.operations.network.ros.ROSNetworkPublisherFactory; @@ -15,9 +19,18 @@ public final class GripNetworkModule extends AbstractModule { @Override protected void configure() { + // HTTP server injection bindings + bind(GripServer.JettyServerFactory.class).to(GripServer.JettyServerFactoryImpl.class); + bind(GripServer.class).asEagerSingleton(); + bind(HttpPipelineSwitcher.class).asEagerSingleton(); + bind(DataHandler.class).asEagerSingleton(); + // Network publishing bindings bind(MapNetworkPublisherFactory.class) .annotatedWith(Names.named("ntManager")) .to(NTManager.class); + bind(MapNetworkPublisherFactory.class) + .annotatedWith(Names.named("httpManager")) + .to(HttpPublishManager.class); bind(ROSNetworkPublisherFactory.class) .annotatedWith(Names.named("rosManager")) .to(ROSManager.class); diff --git a/core/src/main/java/edu/wpi/grip/core/operations/network/http/DataHandler.java b/core/src/main/java/edu/wpi/grip/core/operations/network/http/DataHandler.java new file mode 100644 index 0000000000..a4887cb336 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/operations/network/http/DataHandler.java @@ -0,0 +1,137 @@ +package edu.wpi.grip.core.operations.network.http; + +import edu.wpi.grip.core.events.RunStartedEvent; +import edu.wpi.grip.core.events.RunStoppedEvent; +import edu.wpi.grip.core.http.ContextStore; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.PedanticHandler; + +import com.google.common.eventbus.Subscribe; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import org.eclipse.jetty.server.Request; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static com.google.common.base.Preconditions.checkNotNull; +import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED; +import static javax.servlet.http.HttpServletResponse.SC_OK; + +/** + * Jetty handler for sending HTTP publishing data to a client. + * Only one instance of this class should exist at a time. + */ +@Singleton +public final class DataHandler extends PedanticHandler { + + /** + * Json serializer. + */ + private final Gson gson; + + /** + * Map of data supplier to their names. + */ + private final Map> dataSuppliers; + + /** + * Atomic flag set when the pipeline is running so that requests for data won't get stale data. + */ + private final AtomicBoolean staleData; + + /** + * Lock used to park the handler thread while the pipeline is running. + */ + private final Object runningLock = new Object(); + + @Inject + DataHandler(ContextStore store) { + super(store, GripServer.DATA_PATH, true); + this.dataSuppliers = new HashMap<>(); + this.gson = new GsonBuilder() + .setPrettyPrinting() + .serializeSpecialFloatingPointValues() + .create(); + this.staleData = new AtomicBoolean(false); + } + + @Override + protected void handleIfPassed(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + if (!isGet(request)) { + // Only allow GET on the data path + response.setStatus(SC_METHOD_NOT_ALLOWED); + baseRequest.setHandled(true); + return; + } + while (staleData.get()) { + synchronized (runningLock) { + try { + runningLock.wait(); + } catch (InterruptedException e) { + throw new ServletException("Could not lock the HTTP data handler thread", e); + } + } + } + final Map uriParameters = request.getParameterMap(); + Map requestedData = dataSuppliers.entrySet() + .stream() + .filter(e -> uriParameters.isEmpty() || uriParameters.containsKey(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get())); + String json = gson.toJson(requestedData); + sendTextContent(response, json, CONTENT_TYPE_JSON); + response.setStatus(SC_OK); + baseRequest.setHandled(true); + } + + /** + * Adds a supplier for data with the given name. + * The data will be published to {@code /GRIP/data} on the internal HTTP server. + * + * @param name the name of the data + * @param supplier a supplier for the data + */ + public void addDataSupplier(String name, Supplier supplier) { + checkNotNull(name, "name"); + checkNotNull(supplier, "supplier"); + dataSuppliers.put(name, supplier); + } + + /** + * Removes the supplier for data with the given name. Will do nothing if no such data exists, + * or if {@code name} is null. + * + * @param name the name of the data to remove + */ + public void removeDataSupplier(@Nullable String name) { + dataSuppliers.remove(name); + } + + @Subscribe + public void onPipelineStart(@Nullable RunStartedEvent e) { + staleData.set(true); + } + + @Subscribe + public void onPipelineStop(@Nullable RunStoppedEvent e) { + staleData.set(false); + synchronized (runningLock) { + runningLock.notify(); + } + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/operations/network/http/HttpPublishManager.java b/core/src/main/java/edu/wpi/grip/core/operations/network/http/HttpPublishManager.java new file mode 100644 index 0000000000..913951fd87 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/operations/network/http/HttpPublishManager.java @@ -0,0 +1,71 @@ +package edu.wpi.grip.core.operations.network.http; + +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.operations.network.Manager; +import edu.wpi.grip.core.operations.network.MapNetworkPublisher; +import edu.wpi.grip.core.operations.network.MapNetworkPublisherFactory; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Manager for publishing data to the internal HTTP server. + */ +@Singleton +public class HttpPublishManager implements Manager, MapNetworkPublisherFactory { + + private final DataHandler dataHandler; + + @Inject + public HttpPublishManager(GripServer server, DataHandler dataHandler) { + this.dataHandler = dataHandler; + server.addHandler(dataHandler); + } + + @Override + public MapNetworkPublisher create(Set keys) { + return new HttpPublisher<>(dataHandler, keys); + } + + private static final class HttpPublisher

extends MapNetworkPublisher

{ + + private final DataHandler dataHandler; + private String name; + + HttpPublisher(final DataHandler dataHandler, final Set keys) { + super(keys); + this.dataHandler = dataHandler; + } + + @Override + protected void doPublish() { + close(); + } + + @Override + protected void doPublish(Map publishMap) { + dataHandler.addDataSupplier(name, () -> publishMap); + } + + @Override + protected void doPublishSingle(P value) { + dataHandler.removeDataSupplier(name); + dataHandler.addDataSupplier(name, () -> value); + } + + @Override + protected void publishNameChanged(Optional oldName, String newName) { + oldName.ifPresent(dataHandler::removeDataSupplier); + this.name = newName; + } + + @Override + public void close() { + dataHandler.removeDataSupplier(name); + } + } +} diff --git a/core/src/main/java/edu/wpi/grip/core/operations/network/http/HttpPublishOperation.java b/core/src/main/java/edu/wpi/grip/core/operations/network/http/HttpPublishOperation.java new file mode 100644 index 0000000000..de98f2a395 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/operations/network/http/HttpPublishOperation.java @@ -0,0 +1,52 @@ +package edu.wpi.grip.core.operations.network.http; + +import edu.wpi.grip.core.OperationDescription; +import edu.wpi.grip.core.operations.network.MapNetworkPublisherFactory; +import edu.wpi.grip.core.operations.network.PublishAnnotatedOperation; +import edu.wpi.grip.core.operations.network.Publishable; +import edu.wpi.grip.core.sockets.InputSocket; +import edu.wpi.grip.core.util.Icon; + +import java.util.function.Function; + +/** + * An operation for publishing data to the internal HTTP server, from which which remote + * applications can request the data. + * + * @see edu.wpi.grip.core.http.GripServer + */ +public class HttpPublishOperation + extends PublishAnnotatedOperation { + + @SuppressWarnings("unchecked") + public HttpPublishOperation(InputSocket.Factory isf, + Class

dataType, + MapNetworkPublisherFactory factory) { + this(isf, (Class) dataType, dataType, d -> (P) d, factory); + } + + public HttpPublishOperation(InputSocket.Factory isf, + Class dataType, + Class

publishType, + Function converter, + MapNetworkPublisherFactory factory) { + super(isf, dataType, publishType, converter, factory); + super.nameSocket.setValue("my" + dataType.getSimpleName()); + } + + /** + * Gets a description for an {@code HttpPublishOperation} that publishes the given data type. + * + * @param dataType the type of the data published by the {@code HttpPublishOperation} for the data + * type described + * @return a description for an {@code HttpPublishOperation} that publishes the given data type + */ + public static OperationDescription descriptionFor(Class dataType) { + return OperationDescription.builder() + .name("HTTP Publish " + dataType.getSimpleName()) + .summary("Publishes a " + dataType.getSimpleName() + " to the internal HTTP server") + .icon(Icon.iconStream("publish")) + .category(OperationDescription.Category.NETWORK) + .build(); + } +} \ No newline at end of file diff --git a/core/src/main/java/edu/wpi/grip/core/serialization/Project.java b/core/src/main/java/edu/wpi/grip/core/serialization/Project.java index 43c3272328..e9a6416f3d 100644 --- a/core/src/main/java/edu/wpi/grip/core/serialization/Project.java +++ b/core/src/main/java/edu/wpi/grip/core/serialization/Project.java @@ -1,6 +1,7 @@ package edu.wpi.grip.core.serialization; import edu.wpi.grip.core.Pipeline; +import edu.wpi.grip.core.PipelineRunner; import com.google.common.annotations.VisibleForTesting; import com.google.common.reflect.ClassPath; @@ -15,6 +16,7 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.Reader; +import java.io.StringReader; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.util.Optional; @@ -31,6 +33,8 @@ public class Project { protected final XStream xstream = new XStream(new PureJavaReflectionProvider()); @Inject private Pipeline pipeline; + @Inject + private PipelineRunner pipelineRunner; private Optional file = Optional.empty(); @Inject @@ -87,10 +91,24 @@ public void open(File file) throws IOException { this.file = Optional.of(file); } + /** + * Loads the project defined by the given XML string. This is intended to be used to be able to + * programmatically run a pipeline from a remote source. Therefore, this does not + * save the contents to disk; if it is called in GUI mode, the user will have to manually save the + * file. + * + * @param projectXml the XML string defining the project to open + */ + public void open(String projectXml) { + open(new StringReader(projectXml)); + } + @VisibleForTesting void open(Reader reader) { + pipelineRunner.stopAndAwait(); this.pipeline.clear(); this.xstream.fromXML(reader); + pipelineRunner.startAsync(); } /** diff --git a/core/src/main/java/edu/wpi/grip/core/settings/ProjectSettings.java b/core/src/main/java/edu/wpi/grip/core/settings/ProjectSettings.java index 918f7c0c0c..173ea05d49 100644 --- a/core/src/main/java/edu/wpi/grip/core/settings/ProjectSettings.java +++ b/core/src/main/java/edu/wpi/grip/core/settings/ProjectSettings.java @@ -43,6 +43,10 @@ public class ProjectSettings implements Cloneable { private String deployJvmOptions = "-Xmx50m -XX:-OmitStackTraceInFastThrow " + "-XX:+HeapDumpOnOutOfMemoryError"; + @Setting(label = "Internal server port", + description = "The port that the internal server should run on.") + private int serverPort = 8080; + public int getTeamNumber() { return teamNumber; } @@ -135,6 +139,16 @@ private String computeFRCAddress(int teamNumber) { return "roborio-" + teamNumber + "-frc.local"; } + public int getServerPort() { + return serverPort; + } + + public void setServerPort(@Nonnegative int serverPort) { + checkArgument(serverPort >= 1024 && serverPort <= 65535, + "Server port must be in the range 1024..65535"); + this.serverPort = serverPort; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -145,6 +159,7 @@ public String toString() { .add("deployJvmOptions", deployJvmOptions) .add("publishAddress", publishAddress) .add("teamNumber", teamNumber) + .add("serverPort", serverPort) .toString(); } diff --git a/core/src/main/java/edu/wpi/grip/core/sources/HttpImageHandler.java b/core/src/main/java/edu/wpi/grip/core/sources/HttpImageHandler.java new file mode 100644 index 0000000000..4899aab297 --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/sources/HttpImageHandler.java @@ -0,0 +1,127 @@ +package edu.wpi.grip.core.sources; + +import edu.wpi.grip.core.http.ContextStore; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.PedanticHandler; + +import org.apache.commons.io.IOUtils; +import org.bytedeco.javacpp.opencv_core.Mat; +import org.eclipse.jetty.server.Request; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import javax.annotation.Nullable; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static com.google.common.base.Preconditions.checkNotNull; +import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED; +import static javax.servlet.http.HttpServletResponse.SC_CREATED; +import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED; + +/** + * Jetty handler for incoming images to be used by {@link HttpSource}. + * Only one instance of this class can exist for a context. + * + *

This handler will return one of the following status codes to a request on + * {@code /GRIP/upload/image}: + *

    + *
  • 405 - Not Allowed: if the request is not a POST
  • + *
  • 202 - Accepted: if the image sent is the same as the previous one
  • + *
  • 201 - Created: if the image was successfully handled
  • + *
+ */ +public final class HttpImageHandler extends PedanticHandler { + + /** + * Callbacks that take OpenCV Mats. These will be called when a new image is posted. + */ + private final List> callbacks; + + /** + * The most recent image. Could be a local field, but this is more memory-efficient. + */ + private Mat image = null; + + /** + * The most recent bytes received. + */ + private byte[] lastBytes = null; + + /** + * Creates an image handler on the default upload path {@code /GRIP/upload/image}. + */ + public HttpImageHandler(ContextStore store) { + this(store, GripServer.IMAGE_UPLOAD_PATH); + } + + /** + * Creates an image handler on the given path. + * + * @param path the path on the server that images will be uploaded to + */ + public HttpImageHandler(ContextStore store, String path) { + super(store, path, true); + callbacks = new ArrayList<>(); + } + + @Override + protected void handleIfPassed(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + if (!isPost(request)) { + response.setStatus(SC_METHOD_NOT_ALLOWED); + baseRequest.setHandled(true); + return; + } + byte[] newBytes = IOUtils.toByteArray(request.getInputStream()); + if (Arrays.equals(lastBytes, newBytes)) { + // no change + response.setStatus(SC_ACCEPTED); + baseRequest.setHandled(true); + return; + } + image = new Mat(newBytes); + lastBytes = newBytes; + callbacks.forEach(c -> c.accept(image)); + response.setStatus(SC_CREATED); + baseRequest.setHandled(true); + } + + /** + * Gets the most recently POSTed image. + */ + public Optional getImage() { + return Optional.ofNullable(image); + } + + /** + * Adds a callback to this handler. The callback will be called when a new image is POSTed to + * {@code /GRIP/upload/image} and can be removed later with {@link #removeCallback(Consumer)}. + * + * @param callback the callback to add + * @see #removeCallback(Consumer) + */ + public void addCallback(Consumer callback) { + callbacks.add(checkNotNull(callback)); + } + + /** + * Removes the given callback from this handler. The callback will no longer be called when a new + * image is POSTed to {@code /GRIP/upload/image}, unless it is re-added with + * {@link #addCallback(Consumer)}. Does nothing if {@code callback} is {@code null}. + * + * @param callback the callback to remove + * @see #addCallback(Consumer) + */ + public void removeCallback(@Nullable Consumer callback) { + callbacks.remove(callback); + } +} \ No newline at end of file diff --git a/core/src/main/java/edu/wpi/grip/core/sources/HttpSource.java b/core/src/main/java/edu/wpi/grip/core/sources/HttpSource.java new file mode 100644 index 0000000000..586f3a90fb --- /dev/null +++ b/core/src/main/java/edu/wpi/grip/core/sources/HttpSource.java @@ -0,0 +1,148 @@ + +package edu.wpi.grip.core.sources; + +import edu.wpi.grip.core.Source; +import edu.wpi.grip.core.events.SourceHasPendingUpdateEvent; +import edu.wpi.grip.core.events.SourceRemovedEvent; +import edu.wpi.grip.core.http.ContextStore; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.sockets.OutputSocket; +import edu.wpi.grip.core.sockets.SocketHint; +import edu.wpi.grip.core.sockets.SocketHints; +import edu.wpi.grip.core.util.ExceptionWitness; + +import com.google.common.collect.ImmutableList; +import com.google.common.eventbus.EventBus; +import com.google.common.eventbus.Subscribe; +import com.google.inject.assistedinject.Assisted; +import com.google.inject.assistedinject.AssistedInject; +import com.thoughtworks.xstream.annotations.XStreamAlias; + +import org.bytedeco.javacpp.opencv_core.Mat; +import org.bytedeco.javacpp.opencv_imgcodecs; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.function.Consumer; + +/** + * Provides a way to generate a {@link Mat Mat} from an image that has been POSTed to the + * internal HTTP server. + *

+ * Note that multiple {@link HttpSource HttpSources} will all supply the same image + * (or, more precisely, the same reference to a single image). + *

+ */ +@XStreamAlias("grip:HttpImage") +public class HttpSource extends Source { + + /** + * Map of handlers to their paths to avoid having multiple handlers per path. + */ + private static final Map handlers = new HashMap<>(); + + private static final String PATH_PROPERTY = "image_upload_path"; + + /** + * HTTP handler. Fires callbacks when a new image has been POSTed to /GRIP/upload/image + */ + private final HttpImageHandler imageHandler; + + private final OutputSocket imageOutput; + private final SocketHint outputHint = SocketHints.Outputs.createMatSocketHint("Image"); + private final Mat image = new Mat(); + private final Consumer callback; + private final EventBus eventBus; + private String path; + + public interface Factory { + HttpSource create(Properties properties); + + HttpSource create(String path); + } + + @AssistedInject + HttpSource( + ExceptionWitness.Factory exceptionWitnessFactory, + EventBus eventBus, + OutputSocket.Factory osf, + GripServer server, + ContextStore store, + @Assisted Properties properties) { + this(exceptionWitnessFactory, + eventBus, + osf, + server, + store, + properties.getProperty(PATH_PROPERTY)); + } + + @AssistedInject + HttpSource( + ExceptionWitness.Factory exceptionWitnessFactory, + EventBus eventBus, + OutputSocket.Factory osf, + GripServer server, + ContextStore store, + @Assisted String path) { + super(exceptionWitnessFactory); + this.path = path; + this.imageHandler = handlers.computeIfAbsent(path, p -> new HttpImageHandler(store, p)); + this.imageOutput = osf.create(outputHint); + this.eventBus = eventBus; + // Will add the handler only when the first HttpSource is created -- no-op every subsequent time + // (Otherwise, multiple handlers would be getting called and it'd be a mess) + server.addHandler(imageHandler); + this.callback = this::setImage; + } + + private void setImage(Mat image) { + image.copyTo(this.image); + eventBus.post(new SourceHasPendingUpdateEvent(this)); + } + + @Override + public String getName() { + return path; + } + + @Override + protected List createOutputSockets() { + return ImmutableList.of( + imageOutput + ); + } + + @Override + protected boolean updateOutputSockets() { + if (image.empty()) { + // No data, don't bother converting + return false; + } + imageOutput.setValue(opencv_imgcodecs.imdecode(image, opencv_imgcodecs.CV_LOAD_IMAGE_COLOR)); + return true; + } + + @Override + public Properties getProperties() { + Properties properties = new Properties(); + properties.setProperty(PATH_PROPERTY, path); + return properties; + } + + @Override + public void initialize() { + imageHandler.addCallback(callback); + imageHandler.getImage().ifPresent(this::setImage); + } + + @Subscribe + public void onSourceRemovedEvent(SourceRemovedEvent event) { + if (event.getSource() == this) { + imageHandler.removeCallback(callback); + } + } + +} \ No newline at end of file diff --git a/core/src/test/java/edu/wpi/grip/core/PipelineRunnerTest.java b/core/src/test/java/edu/wpi/grip/core/PipelineRunnerTest.java index 68bd63aedc..08ead65a0e 100644 --- a/core/src/test/java/edu/wpi/grip/core/PipelineRunnerTest.java +++ b/core/src/test/java/edu/wpi/grip/core/PipelineRunnerTest.java @@ -77,7 +77,7 @@ public void tearDown() throws Throwable { @Test public void testRunEmptyPipelineSucceeds() { - PipelineRunner runner = new PipelineRunner(null, () -> ImmutableList.of(), () -> + PipelineRunner runner = new PipelineRunner(new EventBus(), () -> ImmutableList.of(), () -> ImmutableList.of()); runner.addListener(failureListener, MoreExecutors.directExecutor()); runner.startAsync().awaitRunning(); @@ -107,7 +107,7 @@ public void testRunSimplePipeline_WithSourcesAndSteps() throws IOException { @Test public void testStopPipelineEventStopsPipeline() throws TimeoutException { - PipelineRunner runner = new PipelineRunner(null, () -> ImmutableList.of(), () -> + PipelineRunner runner = new PipelineRunner(new EventBus(), () -> ImmutableList.of(), () -> ImmutableList.of()); runner.addListener(failureListener, MoreExecutors.directExecutor()); runner.startAsync().awaitRunning(3, TimeUnit.SECONDS); diff --git a/core/src/test/java/edu/wpi/grip/core/http/GenericHandlerTest.java b/core/src/test/java/edu/wpi/grip/core/http/GenericHandlerTest.java new file mode 100644 index 0000000000..4f9bb5e5d7 --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/http/GenericHandlerTest.java @@ -0,0 +1,80 @@ +package edu.wpi.grip.core.http; + +import org.eclipse.jetty.server.Request; +import org.junit.After; +import org.junit.Test; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class GenericHandlerTest { + + private GenericHandler gh; + private final ContextStore store = new ContextStore(); + + @Test(expected = NullPointerException.class) + public void testNullContext() { + gh = new MockGenericHandler(null); + } + + @Test + @SuppressWarnings("PMD.JUnitTestsShouldIncludeAssert") + public void testNoClaim() { + gh = new MockGenericHandler(); + gh = new MockGenericHandler("testNoClaim"); + gh = new MockGenericHandler("testNoClaim", false); + // An exception will be thrown if something went wrong + } + + @Test(expected = IllegalArgumentException.class) + public void testClaim() { + String context = "testClaim"; + gh = new MockGenericHandler(context, true); + assertTrue("Context should have been claimed", store.contains(context)); + gh = new MockGenericHandler(context, true); // Should throw IllegalArgumentException + } + + @Test + public void testGetContext() { + String context = "testGetContext"; + gh = new MockGenericHandler(context); + assertEquals("Context was wrong", context, gh.getContext()); + } + + @After + public void tearDown() { + if (gh != null) { + gh.releaseContext(); + } + } + + private class MockGenericHandler extends GenericHandler { + + MockGenericHandler() { + super(); + } + + MockGenericHandler(String context) { + super(store, context); + } + + MockGenericHandler(String context, boolean doClaim) { + super(store, context, doClaim); + } + + @Override + public void handle(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) throws IOException, ServletException { + // Do nothing, this class is only used for testing automatic claims + } + } + +} diff --git a/core/src/test/java/edu/wpi/grip/core/http/GripServerTest.java b/core/src/test/java/edu/wpi/grip/core/http/GripServerTest.java new file mode 100644 index 0000000000..44ef3524b3 --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/http/GripServerTest.java @@ -0,0 +1,165 @@ +package edu.wpi.grip.core.http; + +import edu.wpi.grip.core.exception.GripServerException; +import edu.wpi.grip.core.settings.ProjectSettings; +import edu.wpi.grip.core.settings.SettingsProvider; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpVersion; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.BasicHttpEntity; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.params.CoreProtocolPNames; +import org.apache.http.util.EntityUtils; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.junit.After; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * + */ +public class GripServerTest { + + private final DefaultHttpClient client; + private GripServer instance; + + public static class TestServerFactory implements GripServer.JettyServerFactory { + + private int port; + + public int getPort() { + return port; + } + + @Override + public Server create(int port) { + this.port = 0; + return new Server(0); // 0 -> some random open port, we don't care which + } + + } + + /** + * Public factory method for testing. + */ + public static GripServer makeServer(ContextStore store, + GripServer.JettyServerFactory factory, + SettingsProvider settingsProvider) { + return new GripServer(store, factory, settingsProvider); + } + + public GripServerTest() { + instance = new GripServer(new ContextStore(), new TestServerFactory(), ProjectSettings::new); + instance.start(); + + client = new DefaultHttpClient(); + client.getParams().setParameter(CoreProtocolPNames.PROTOCOL_VERSION, HttpVersion.HTTP_1_1); + } + + @Test + public void testAddRemoveHandler() throws IOException { + String path = "/testAddRemoveHandler"; + boolean[] didRun = {false}; + Handler h = new TestHandler(path, () -> { + didRun[0] = true; + }); + instance.addHandler(h); + HttpResponse response = doPost(path, path.getBytes()); + EntityUtils.consume(response.getEntity()); + assertTrue("Handler should have run", didRun[0]); + didRun[0] = false; + instance.removeHandler(h); + response = doPost(path, path.getBytes()); + EntityUtils.consume(response.getEntity()); + assertFalse("Handler should not have run", didRun[0]); + } + + @Test + public void testSuccessfulHandler() throws IOException { + String path = "/testSuccessfulHandler"; + boolean[] didRun = {false}; + Handler h = new TestHandler(path, () -> { + didRun[0] = true; + }); + instance.addHandler(h); + doPost(path, path.getBytes()); + assertTrue("Handler should have run on " + path, didRun[0]); + } + + @Test + public void testUnsuccessfulPostHandler() throws Exception { + String path = "/testUnsuccessfulPostHandler"; + boolean[] didRun = {false}; + Handler h = new TestHandler(path, () -> { + didRun[0] = true; + throw new GripServerException("Expected"); + }); + instance.addHandler(h); + HttpResponse response = doPost(path, path.getBytes()); + assertEquals("Server should return an internal error (500)", + 500, + response.getStatusLine().getStatusCode()); + assertTrue("Handler should have run", didRun[0]); + } + + @Test + @SuppressWarnings("PMD.JUnitTestsShouldIncludeAssert") + public void testStartStop() throws GripServerException { + instance.start(); // should do nothing since the server's already running + instance.stop(); // stop the server so we know we can start it + instance.stop(); // second call should do nothing + instance.start(); // restart the server + instance.start(); // second call should do nothing + instance.restart(); // should stop and then start again + // No asserts or fails -- if something goes wrong, it would have thrown an exception + } + + @After + public void tearDown() { + if (instance.getState() == GripServer.State.RUNNING) { + instance.stop(); + } + } + + private HttpResponse doPost(String path, byte[] bytes) throws IOException { + HttpPost post = new HttpPost("http://localhost:" + instance.getPort() + path); + BasicHttpEntity httpEntity = new BasicHttpEntity(); + httpEntity.setContent(new ByteArrayInputStream(bytes)); + post.setEntity(httpEntity); + return client.execute(post); + } + + private static final class TestHandler extends PedanticHandler { + + private final Runnable runner; + + private TestHandler(String path, Runnable runner) { + super(new ContextStore(), path); + this.runner = runner; + } + + @Override + protected void handleIfPassed(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) + throws IOException, ServletException { + runner.run(); + baseRequest.setHandled(true); + } + } + +} diff --git a/core/src/test/java/edu/wpi/grip/core/http/HttpPipelineSwitcherTest.java b/core/src/test/java/edu/wpi/grip/core/http/HttpPipelineSwitcherTest.java new file mode 100644 index 0000000000..f04eb9cb82 --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/http/HttpPipelineSwitcherTest.java @@ -0,0 +1,129 @@ +package edu.wpi.grip.core.http; + +import edu.wpi.grip.core.serialization.Project; +import edu.wpi.grip.core.settings.ProjectSettings; +import edu.wpi.grip.core.util.GripMode; + +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.BasicHttpEntity; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class HttpPipelineSwitcherTest { + + private GripServer server; + private Project project; + private HttpPipelineSwitcher pipelineSwitcher; + private HttpClient client; + + @Before + public void setUp() { + server = new GripServer(new ContextStore(), new GripServerTest.TestServerFactory(), + ProjectSettings::new); + client = HttpClients.createDefault(); + server.start(); + } + + @Test + public void testNotPost() throws IOException { + project = new Project() { + @Override + public void open(String projectXml) { + fail("This should not have been called"); + } + }; + pipelineSwitcher = new HttpPipelineSwitcher(new ContextStore(), project, GripMode.HEADLESS); + server.addHandler(pipelineSwitcher); + HttpResponse response = doGet(GripServer.PIPELINE_UPLOAD_PATH); + EntityUtils.consume(response.getEntity()); + } + + @Test + public void testByteConversion() throws IOException { + String payload = "Lorem ipsum"; + boolean[] didRun = {false}; + project = new Project() { + @Override + public void open(String projectXml) { + didRun[0] = true; + assertEquals("Payload was not converted correctly", payload, projectXml); + } + }; + pipelineSwitcher = new HttpPipelineSwitcher(new ContextStore(), project, GripMode.HEADLESS); + server.addHandler(pipelineSwitcher); + HttpResponse response = doPost(GripServer.PIPELINE_UPLOAD_PATH, payload); + EntityUtils.consume(response.getEntity()); + assertTrue("Project was not opened", didRun[0]); + } + + @Test + public void testHeadless() throws IOException { + boolean[] didRun = {false}; + project = new Project() { + @Override + public void open(String projectXml) { + didRun[0] = true; + } + }; + pipelineSwitcher = new HttpPipelineSwitcher(new ContextStore(), project, GripMode.HEADLESS); + server.addHandler(pipelineSwitcher); + HttpResponse response = doPost(GripServer.PIPELINE_UPLOAD_PATH, "dummy data"); + assertEquals("HTTP status should be 201 Created", + 201, + response.getStatusLine().getStatusCode()); + EntityUtils.consume(response.getEntity()); + assertTrue("Project was not opened", didRun[0]); + } + + @Test + public void testGui() throws IOException { + Project project = new Project() { + @Override + public void open(String projectXml) { + fail("Project should not be opened"); + } + }; + pipelineSwitcher = new HttpPipelineSwitcher(new ContextStore(), project, GripMode.GUI); + server.addHandler(pipelineSwitcher); + HttpResponse response = doPost(GripServer.PIPELINE_UPLOAD_PATH, "dummy data"); + assertEquals("HTTP status should be 500 Internal Error", + 500, + response.getStatusLine().getStatusCode()); + EntityUtils.consume(response.getEntity()); + } + + @After + public void tearDown() { + server.stop(); + server.removeHandler(pipelineSwitcher); + pipelineSwitcher.releaseContext(); + } + + private HttpResponse doGet(String path) throws IOException { + String uri = "http://localhost:" + server.getPort() + path; + HttpGet get = new HttpGet(uri); + return client.execute(get); + } + + private HttpResponse doPost(String path, String text) throws IOException { + HttpPost post = new HttpPost("http://localhost:" + server.getPort() + path); + BasicHttpEntity httpEntity = new BasicHttpEntity(); + httpEntity.setContent(new ByteArrayInputStream(text.getBytes())); + post.setEntity(httpEntity); + return client.execute(post); + } + +} diff --git a/core/src/test/java/edu/wpi/grip/core/http/NoContextHandlerTest.java b/core/src/test/java/edu/wpi/grip/core/http/NoContextHandlerTest.java new file mode 100644 index 0000000000..a690548b70 --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/http/NoContextHandlerTest.java @@ -0,0 +1,99 @@ +package edu.wpi.grip.core.http; + +import org.apache.http.HttpVersion; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.BasicHttpEntity; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.params.CoreProtocolPNames; +import org.apache.http.util.EntityUtils; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.HandlerCollection; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static org.junit.Assert.assertEquals; + +public class NoContextHandlerTest { + + private final ContextStore contextStore = new ContextStore(); + private HandlerCollection handlers = new HandlerCollection(); + private NoContextHandler handler = new NoContextHandler(contextStore); + private Server server; + private DefaultHttpClient client; + + @Before + public void setUp() throws Exception { + client = new DefaultHttpClient(); + client.getParams().setParameter(CoreProtocolPNames.PROTOCOL_VERSION, HttpVersion.HTTP_1_1); + handlers.addHandler(handler); + handlers.addHandler(new ClaimingHandler("/someClaimedPath", true)); + server = new Server(0); + server.setHandler(handlers); + server.start(); + } + + @Test + public void testNoContext() throws IOException { + CloseableHttpResponse response; + response = sendHttpRequest("/someUnclaimedPath"); + assertEquals("NoContextHandler should have run and sent 404 status", + 404, + response.getStatusLine().getStatusCode()); + EntityUtils.consume(response.getEntity()); + + response = sendHttpRequest("/someClaimedPath"); + assertEquals("ClaimingHandler should have run and sent 200 status", + 200, + response.getStatusLine().getStatusCode()); + EntityUtils.consume(response.getEntity()); + } + + @After + public void tearDown() throws Exception { + server.stop(); + for (Handler h : handlers.getHandlers()) { + ((GenericHandler) h).releaseContext(); + } + } + + private CloseableHttpResponse sendHttpRequest(String path) throws IOException { + HttpPost post = new HttpPost("http://localhost:" + getServerPort() + path); + BasicHttpEntity httpEntity = new BasicHttpEntity(); + httpEntity.setContent(new ByteArrayInputStream("http_request_bytes".getBytes())); + post.setEntity(httpEntity); + return client.execute(post); + } + + private int getServerPort() { + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + private class ClaimingHandler extends PedanticHandler { + + private ClaimingHandler(String context, boolean doClaim) { + super(contextStore, context, doClaim); + } + + @Override + protected void handleIfPassed(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) + throws IOException, ServletException { + baseRequest.setHandled(true); + response.setStatus(HttpServletResponse.SC_OK); + } + } +} diff --git a/core/src/test/java/edu/wpi/grip/core/http/PedanticHandlerTest.java b/core/src/test/java/edu/wpi/grip/core/http/PedanticHandlerTest.java new file mode 100644 index 0000000000..67d34c79e1 --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/http/PedanticHandlerTest.java @@ -0,0 +1,135 @@ +package edu.wpi.grip.core.http; + +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.BasicHttpEntity; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.HandlerCollection; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PedanticHandlerTest { + + private final ContextStore contextStore = new ContextStore(); + private HandlerCollection handlers; + private MockHandler handler1; + private MockHandler handler2; + private MockHandler handler3; + private Server server; + private HttpClient client; + + @Before + public void setUp() throws Exception { + client = HttpClients.createDefault(); + handlers = new HandlerCollection(true); + server = new Server(0); + server.setHandler(handlers); + server.start(); + } + + @Test + public void testNoClaims() throws IOException, ServletException { + // Handlers 1 and 2 on one path, handler 3 on another + handler1 = new MockHandler("/path/1"); + handler2 = new MockHandler("/path/1"); + handler3 = new MockHandler("/path/3"); + handlers.setHandlers(arr(handler1, handler2, handler3)); + + sendHttpRequest(handler1.getContext()); + assertTrue("Handler 1 should have run", handler1.didRun); + assertTrue("Handler 2 should have run", handler2.didRun); + assertFalse("Handler 3 should not have run", handler3.didRun); + } + + @Test + public void testWithClaims() throws IOException { + // all handlers on separate paths + handler1 = new MockHandler("/path/1", true); + handler2 = new MockHandler("/path/2", true); + handler3 = new MockHandler("/path/3", true); + handlers.setHandlers(arr(handler1, handler2, handler3)); + + sendHttpRequest(handler1.getContext()); + assertTrue("Handler 1 should have run", handler1.didRun); + assertFalse("Handler 2 should not have run", handler2.didRun); + assertFalse("Handler 3 should not have run", handler3.didRun); + } + + @Test + public void testPedantry() throws IOException { + handler1 = new MockHandler("/path"); + handler2 = new MockHandler("/path/"); + handler3 = new MockHandler("/path/3"); + handlers.setHandlers(arr(handler1, handler2, handler3)); + sendHttpRequest(handler1.getContext()); + assertTrue("Handler 1 should have run", handler1.didRun); + assertFalse("Handler 2 should not have run", handler2.didRun); + assertFalse("Handler 3 should not have run", handler3.didRun); + } + + @After + public void tearDown() throws Exception { + handler1.releaseContext(); + handler2.releaseContext(); + handler3.releaseContext(); + server.stop(); + } + + @SafeVarargs + private static T[] arr(T... a) { + // Convert varargs to array because the Jetty API isn't great + return a; + } + + private void sendHttpRequest(String path) throws IOException { + HttpPost post = new HttpPost("http://localhost:" + getServerPort() + path); + BasicHttpEntity httpEntity = new BasicHttpEntity(); + httpEntity.setContent(new ByteArrayInputStream("http_request_bytes".getBytes())); + post.setEntity(httpEntity); + HttpResponse resp = client.execute(post); + EntityUtils.consume(resp.getEntity()); + } + + private int getServerPort() { + return ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + private class MockHandler extends PedanticHandler { + + private boolean didRun = false; + + MockHandler(String context) { + super(contextStore, context); + } + + MockHandler(String context, boolean doClaim) { + super(contextStore, context, doClaim); + } + + @Override + protected void handleIfPassed(String target, + Request baseRequest, + HttpServletRequest request, + HttpServletResponse response) + throws IOException, ServletException { + didRun = true; + } + } + +} diff --git a/core/src/test/java/edu/wpi/grip/core/operations/OperationsFactory.java b/core/src/test/java/edu/wpi/grip/core/operations/OperationsFactory.java index 534d889d08..10cddd575d 100644 --- a/core/src/test/java/edu/wpi/grip/core/operations/OperationsFactory.java +++ b/core/src/test/java/edu/wpi/grip/core/operations/OperationsFactory.java @@ -18,17 +18,21 @@ public class OperationsFactory { public static Operations create(EventBus eventBus) { - - return create(eventBus, MockMapNetworkPublisher::new, MockROSMessagePublisher::new, - new MockInputSocketFactory(eventBus), new MockOutputSocketFactory(eventBus)); + return create(eventBus, + MockMapNetworkPublisher::new, + MockMapNetworkPublisher::new, + MockROSMessagePublisher::new, + new MockInputSocketFactory(eventBus), + new MockOutputSocketFactory(eventBus)); } public static Operations create(EventBus eventBus, MapNetworkPublisherFactory mapFactory, + MapNetworkPublisherFactory httpFactory, ROSNetworkPublisherFactory rosFactory, InputSocket.Factory isf, OutputSocket.Factory osf) { - return new Operations(eventBus, mapFactory, rosFactory, isf, osf); + return new Operations(eventBus, mapFactory, httpFactory, rosFactory, isf, osf); } public static CVOperations createCV(EventBus eventBus) { diff --git a/core/src/test/java/edu/wpi/grip/core/operations/network/MockGripNetworkModule.java b/core/src/test/java/edu/wpi/grip/core/operations/network/MockGripNetworkModule.java index 1ee59689cc..d3017c439e 100644 --- a/core/src/test/java/edu/wpi/grip/core/operations/network/MockGripNetworkModule.java +++ b/core/src/test/java/edu/wpi/grip/core/operations/network/MockGripNetworkModule.java @@ -7,7 +7,7 @@ import com.google.inject.name.Names; /** - * A mock of {@Link GRIPNetworkModule} for testing. + * A mock of {@link GripNetworkModule} for testing. */ public final class MockGripNetworkModule extends AbstractModule { @Override @@ -15,6 +15,9 @@ protected void configure() { bind(MapNetworkPublisherFactory.class) .annotatedWith(Names.named("ntManager")) .to(MockMapNetworkPublisher.class); + bind(MapNetworkPublisherFactory.class) + .annotatedWith(Names.named("httpManager")) + .to(MockMapNetworkPublisher.class); bind(ROSNetworkPublisherFactory.class) .annotatedWith(Names.named("rosManager")) .to(MockROSManager.class); diff --git a/core/src/test/java/edu/wpi/grip/core/operations/network/http/HttpPublisherTest.java b/core/src/test/java/edu/wpi/grip/core/operations/network/http/HttpPublisherTest.java new file mode 100644 index 0000000000..d242561c7f --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/operations/network/http/HttpPublisherTest.java @@ -0,0 +1,182 @@ +package edu.wpi.grip.core.operations.network.http; + +import edu.wpi.grip.core.events.RunStartedEvent; +import edu.wpi.grip.core.events.RunStoppedEvent; +import edu.wpi.grip.core.http.ContextStore; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.GripServerTest; +import edu.wpi.grip.core.operations.network.NumberPublishable; +import edu.wpi.grip.core.settings.ProjectSettings; +import edu.wpi.grip.core.sockets.InputSocket; +import edu.wpi.grip.core.sockets.MockInputSocketFactory; + +import com.google.common.eventbus.EventBus; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.BasicHttpEntity; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class HttpPublisherTest { + + private static final String dataPath = "/GRIP/data"; + private static final String noDataPath = "/GRIP/data?no_data_here"; + private static final String empty = "{}"; + private static final String name = "foo"; + private static final String json = "{\n \"foo\": 1.0\n}"; + private static final String unexpectedResponseMsg = "Unexpected response to data request"; + + private EventBus eventBus; + private GripServer server; + private DataHandler dataHandler; + private HttpPublishOperation operation; + + private HttpClient client; + + @Rule + public final Timeout timeout = new Timeout(10000, TimeUnit.MILLISECONDS); + + @Before + public void setUp() { + eventBus = new EventBus(); + ContextStore contextStore = new ContextStore(); + InputSocket.Factory isf = new MockInputSocketFactory(eventBus); + server = GripServerTest.makeServer( + contextStore, new GripServerTest.TestServerFactory(), ProjectSettings::new); + dataHandler = new DataHandler(contextStore); + eventBus.register(dataHandler); + + operation = new HttpPublishOperation<>( + isf, + Number.class, + NumberPublishable.class, + NumberPublishable::new, + new HttpPublishManager(server, dataHandler) + ); + + client = HttpClients.createDefault(); + + server.addHandler(dataHandler); + server.start(); + } + + @SuppressWarnings("unchecked") + private void perform() { + List inputs = operation.getInputSockets(); + inputs.get(0).setValue(1.0); + inputs.get(1).setValue(name); + operation.perform(); + } + + @Test + public void testNoData() throws IOException { + assertEquals("Data was not empty", empty, doGetText(dataPath)); + assertEquals("Shouldn't be data on this path", empty, doGetText(noDataPath)); + } + + @Test + public void testWithData() throws IOException { + perform(); + assertEquals(unexpectedResponseMsg, json, doGetText(dataPath)); + assertEquals("Shouldn't be data on this path", empty, doGetText(noDataPath)); + } + + @Test + public void testWhenPipelineRunning() throws IOException { + perform(); + // Stop the pipeline after (about) 500ms + new Timer().schedule(new TimerTask() { + @Override + public void run() { + eventBus.post(new RunStoppedEvent()); + } + }, 500); + // Start the pipeline. Will get stopped in a bit by the timer task + eventBus.post(new RunStartedEvent()); + doGet(dataPath); // should block + assertEquals("Data handler should have run", json, doGetText(dataPath)); + } + + @Test + public void testNotPost() throws IOException { + dataHandler.addDataSupplier("fail", () -> { + fail("This should not have been called"); + return null; + }); + HttpResponse response = doPost(dataPath); + assertEquals("Server should have returned a 405 status", + 405, + response.getStatusLine().getStatusCode()); + } + + @Test + public void testDataSuppliers() throws IOException { + perform(); + dataHandler.addDataSupplier("some_data", () -> "some_value"); + assertEquals(unexpectedResponseMsg, + "{\n \"foo\": 1.0,\n \"some_data\": \"some_value\"\n}", + doGetText(dataPath)); + assertEquals(unexpectedResponseMsg, json, doGetText("/GRIP/data?foo")); + dataHandler.removeDataSupplier("some_data"); + assertEquals(unexpectedResponseMsg, json, doGetText("/GRIP/data?foo")); + assertEquals(unexpectedResponseMsg, json, doGetText(dataPath)); + } + + @Test(expected = NullPointerException.class) + public void testNullSupplierName() { + dataHandler.addDataSupplier(null, () -> "null_supplier_name"); + } + + @Test(expected = NullPointerException.class) + public void testNullSupplier() { + dataHandler.addDataSupplier("null_supplier", null); + } + + @After + public void tearDown() { + server.removeHandler(dataHandler); + server.stop(); + } + + private String doGetText(String path) throws IOException { + HttpEntity entity = doGet(path).getEntity(); + String s = EntityUtils.toString(entity); + EntityUtils.consume(entity); + return s; + } + + private HttpResponse doGet(String path) throws IOException { + String uri = "http://localhost:" + server.getPort() + path; + HttpGet get = new HttpGet(uri); + return client.execute(get); + } + + private HttpResponse doPost(String path) throws IOException { + String uri = "http://localhost:" + server.getPort() + path; + HttpPost post = new HttpPost(uri); + BasicHttpEntity entity = new BasicHttpEntity(); + entity.setContent(new ByteArrayInputStream(new byte[0])); + post.setEntity(entity); + return client.execute(post); + } + +} \ No newline at end of file diff --git a/core/src/test/java/edu/wpi/grip/core/sources/HttpSourceTest.java b/core/src/test/java/edu/wpi/grip/core/sources/HttpSourceTest.java new file mode 100644 index 0000000000..e4bfbac59f --- /dev/null +++ b/core/src/test/java/edu/wpi/grip/core/sources/HttpSourceTest.java @@ -0,0 +1,103 @@ +package edu.wpi.grip.core.sources; + +import edu.wpi.grip.core.http.ContextStore; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.GripServerTest; +import edu.wpi.grip.core.settings.ProjectSettings; +import edu.wpi.grip.core.sockets.MockOutputSocketFactory; +import edu.wpi.grip.core.sockets.OutputSocket; +import edu.wpi.grip.core.util.MockExceptionWitness; +import edu.wpi.grip.util.Files; + +import com.google.common.eventbus.EventBus; + +import org.apache.commons.httpclient.URIException; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.FileEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.bytedeco.javacpp.opencv_core.Mat; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * + */ +public class HttpSourceTest { + + private File logoFile; + + private GripServer server; + private HttpSource source; + private CloseableHttpClient postClient; + + @Before + public void setUp() throws URIException, URISyntaxException { + GripServer.JettyServerFactory f = new GripServerTest.TestServerFactory(); + ContextStore contextStore = new ContextStore(); + server = GripServerTest.makeServer(contextStore, f, ProjectSettings::new); + server.start(); + EventBus eventBus = new EventBus(); + OutputSocket.Factory osf = new MockOutputSocketFactory(eventBus); + source = new HttpSource( + origin -> new MockExceptionWitness(eventBus, origin), + eventBus, + osf, + server, + contextStore, + GripServer.IMAGE_UPLOAD_PATH); + + logoFile = new File(Files.class.getResource("/edu/wpi/grip/images/GRIP_Logo.png").toURI()); + postClient = HttpClients.createDefault(); + } + + @Test + public void testPostImage() throws IOException, InterruptedException { + OutputSocket imageSource = source.getOutputSockets().get(0); + + // We have to manually update the output sockets to get the image + source.updateOutputSockets(); + assertTrue( + "The value should not be present if the source hasn't been initialized and no image POSTed", + imageSource.getValue().get().empty()); + + source.initialize(); // adds the source as a PostHandler to the server + source.updateOutputSockets(); + assertTrue( + "The value should not be present since the source has been initialized but no image POSTed", + imageSource.getValue().get().empty()); + + doPost(GripServer.IMAGE_UPLOAD_PATH, logoFile); + source.updateOutputSockets(); + assertFalse( + "The value should now be present after POSTing the image", + imageSource.getValue().get().empty()); + } + + // POSTs the given image file to the given path on the server + private void doPost(String path, File imageFile) throws IOException { + final String uri = "http://localhost:" + server.getPort() + path; + final HttpPost post = new HttpPost(uri); + final FileEntity entity = new FileEntity(imageFile); + post.setEntity(entity); + CloseableHttpResponse response = postClient.execute(post); + EntityUtils.consume(response.getEntity()); + } + + @After + public void tearDown() throws IOException { + server.stop(); + postClient.close(); + } + +} diff --git a/core/src/test/java/edu/wpi/grip/core/sources/SourcesSanityTest.java b/core/src/test/java/edu/wpi/grip/core/sources/SourcesSanityTest.java index d70c0ac34a..112dd74e35 100644 --- a/core/src/test/java/edu/wpi/grip/core/sources/SourcesSanityTest.java +++ b/core/src/test/java/edu/wpi/grip/core/sources/SourcesSanityTest.java @@ -1,6 +1,10 @@ package edu.wpi.grip.core.sources; +import edu.wpi.grip.core.http.ContextStore; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.GripServerTest; +import edu.wpi.grip.core.settings.ProjectSettings; import edu.wpi.grip.core.util.ExceptionWitness; import edu.wpi.grip.core.util.MockExceptionWitness; import edu.wpi.grip.core.util.service.SingleActionListener; @@ -15,9 +19,16 @@ public SourcesSanityTest() { super(); publicApiOnly(); ignoreClasses(c -> c.getName().contains("Mock")); - ignoreClasses(c -> Arrays.asList(IPCameraFrameGrabber.class).contains(c)); + ignoreClasses(c -> Arrays.asList(IPCameraFrameGrabber.class, HttpSource.class).contains(c)); setDefault(Service.Listener.class, new SingleActionListener(() -> { })); setDefault(ExceptionWitness.Factory.class, MockExceptionWitness.MOCK_FACTORY); + + GripServer.JettyServerFactory serverFactory = new GripServerTest.TestServerFactory(); + ProjectSettings projectSettings = new ProjectSettings(); + projectSettings.setServerPort(8080); + GripServer server = + GripServerTest.makeServer(new ContextStore(), serverFactory, () -> projectSettings); + setDefault(GripServer.class, server); } } diff --git a/core/src/test/java/edu/wpi/grip/util/GripCoreTestModule.java b/core/src/test/java/edu/wpi/grip/util/GripCoreTestModule.java index 62f585c8b6..a563c4fbe3 100644 --- a/core/src/test/java/edu/wpi/grip/util/GripCoreTestModule.java +++ b/core/src/test/java/edu/wpi/grip/util/GripCoreTestModule.java @@ -2,6 +2,8 @@ import edu.wpi.grip.core.GripCoreModule; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.GripServerTest; import edu.wpi.grip.core.sources.CameraSource; import edu.wpi.grip.core.sources.MockFrameGrabberFactory; @@ -74,6 +76,9 @@ protected void configure() { + "the injector"; bind(CameraSource.FrameGrabberFactory.class).to(MockFrameGrabberFactory.class); super.configure(); + // HTTP server injection bindings + bind(GripServer.JettyServerFactory.class).to(GripServerTest.TestServerFactory.class); + bind(GripServer.class).asEagerSingleton(); } @Override diff --git a/ui/src/main/java/edu/wpi/grip/ui/GripUiModule.java b/ui/src/main/java/edu/wpi/grip/ui/GripUiModule.java index fa6964183a..a6fd86e0c5 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/GripUiModule.java +++ b/ui/src/main/java/edu/wpi/grip/ui/GripUiModule.java @@ -17,6 +17,7 @@ import edu.wpi.grip.ui.pipeline.input.SliderInputSocketController; import edu.wpi.grip.ui.pipeline.input.TextFieldInputSocketController; import edu.wpi.grip.ui.pipeline.source.CameraSourceController; +import edu.wpi.grip.ui.pipeline.source.HttpSourceController; import edu.wpi.grip.ui.pipeline.source.MultiImageFileSourceController; import edu.wpi.grip.ui.pipeline.source.SourceController; @@ -71,6 +72,7 @@ public void hear(final TypeLiteral typeLiteral, TypeEncounter typeEnco })); install(new FactoryModuleBuilder().build(MultiImageFileSourceController.Factory.class)); install(new FactoryModuleBuilder().build(CameraSourceController.Factory.class)); + install(new FactoryModuleBuilder().build(HttpSourceController.Factory.class)); // END Source Factories // Components diff --git a/ui/src/main/java/edu/wpi/grip/ui/Main.java b/ui/src/main/java/edu/wpi/grip/ui/Main.java index 46f8b59584..9164077674 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/Main.java +++ b/ui/src/main/java/edu/wpi/grip/ui/Main.java @@ -3,6 +3,8 @@ import edu.wpi.grip.core.GripCoreModule; import edu.wpi.grip.core.PipelineRunner; import edu.wpi.grip.core.events.UnexpectedThrowableEvent; +import edu.wpi.grip.core.http.GripServer; +import edu.wpi.grip.core.http.HttpPipelineSwitcher; import edu.wpi.grip.core.operations.CVOperations; import edu.wpi.grip.core.operations.Operations; import edu.wpi.grip.core.operations.network.GripNetworkModule; @@ -53,6 +55,8 @@ public class Main extends Application { @Inject private Operations operations; @Inject private CVOperations cvOperations; @Inject private Logger logger; + @Inject private GripServer server; + @Inject private HttpPipelineSwitcher pipelineSwitcher; private Parent root; public static void main(String[] args) { @@ -67,15 +71,15 @@ public void start(Stage stage) throws Exception { if (parameters.contains("--headless")) { // If --headless was specified on the command line, run in headless mode (only use the core // module) - injector = Guice.createInjector(new GripCoreModule(), new GripNetworkModule(), new - GripSourcesHardwareModule()); + injector = Guice.createInjector(Modules.override(new GripCoreModule(), + new GripSourcesHardwareModule()).with(new GripNetworkModule())); injector.injectMembers(this); parameters.remove("--headless"); } else { // Otherwise, run with both the core and UI modules, and show the JavaFX stage - injector = Guice.createInjector(Modules.override(new GripCoreModule(), new - GripNetworkModule(), new GripSourcesHardwareModule()).with(new GripUiModule())); + injector = Guice.createInjector(Modules.override(new GripCoreModule(), + new GripSourcesHardwareModule()).with(new GripNetworkModule(), new GripUiModule())); injector.injectMembers(this); System.setProperty("prism.lcdtext", "false"); @@ -98,6 +102,8 @@ public void start(Stage stage) throws Exception { operations.addOperations(); cvOperations.addOperations(); + server.addHandler(pipelineSwitcher); + server.start(); // If there was a file specified on the command line, open it immediately if (!parameters.isEmpty()) { @@ -154,4 +160,4 @@ public final void onUnexpectedThrowableEvent(UnexpectedThrowableEvent event) { } }); } -} +} \ No newline at end of file diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java index 6f51ba086f..69a77a3e7e 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/AddSourceView.java @@ -2,7 +2,9 @@ import edu.wpi.grip.core.events.SourceAddedEvent; import edu.wpi.grip.core.events.UnexpectedThrowableEvent; +import edu.wpi.grip.core.http.GripServer; import edu.wpi.grip.core.sources.CameraSource; +import edu.wpi.grip.core.sources.HttpSource; import edu.wpi.grip.core.sources.ImageFileSource; import edu.wpi.grip.core.sources.MultiImageFileSource; import edu.wpi.grip.ui.util.DPIUtility; @@ -67,7 +69,8 @@ public class AddSourceView extends HBox { AddSourceView(EventBus eventBus, MultiImageFileSource.Factory multiImageSourceFactory, ImageFileSource.Factory imageSourceFactory, - CameraSource.Factory cameraSourceFactory) { + CameraSource.Factory cameraSourceFactory, + HttpSource.Factory httpSourceFactory) { this.eventBus = eventBus; this.multiImageSourceFactory = multiImageSourceFactory; this.imageSourceFactory = imageSourceFactory; @@ -199,6 +202,33 @@ public class AddSourceView extends HBox { }, e -> dialog.errorText.setText(e.getMessage())); }); + + addButton("Add\nHTTP source", getClass().getResource("/edu/wpi/grip/ui/icons/publish.png"), + mouseEvent -> { + final Parent root = this.getScene().getRoot(); + // Show a dialog to pick the server path images will be uploaded on + final String imageRoot = GripServer.IMAGE_UPLOAD_PATH + "/"; + final TextField serverPath = new TextField(imageRoot); + final SourceDialog dialog = new SourceDialog(root, serverPath); + serverPath.setPromptText("Ex: /GRIP/upload/image/foo"); + serverPath.textProperty().addListener(o -> { + boolean valid = true; + String text = serverPath.getText(); + valid = text.startsWith(imageRoot) && text.length() > imageRoot.length(); + dialog.getDialogPane().lookupButton(ButtonType.OK).setDisable(!valid); + }); + + dialog.setTitle("Choose path"); + dialog.setHeaderText("Enter the image upload path"); + dialog.showAndWait() + .filter(ButtonType.OK::equals) + .ifPresent(bt -> { + final HttpSource httpSource = httpSourceFactory.create(serverPath.getText()); + httpSource.initialize(); + eventBus.post(new SourceAddedEvent(httpSource)); + }); + }); + } /** diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/HttpSourceController.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/HttpSourceController.java new file mode 100644 index 0000000000..9d65c99936 --- /dev/null +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/HttpSourceController.java @@ -0,0 +1,31 @@ + +package edu.wpi.grip.ui.pipeline.source; + +import edu.wpi.grip.core.sources.HttpSource; +import edu.wpi.grip.ui.components.ExceptionWitnessResponderButton; +import edu.wpi.grip.ui.pipeline.OutputSocketController; + +import com.google.common.eventbus.EventBus; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; + +/** + * Provides controls for a {@link HttpSource}. + */ +public class HttpSourceController extends SourceController { + + public interface Factory { + + HttpSourceController create(HttpSource source); + } + + @Inject + HttpSourceController( + final EventBus eventBus, + final OutputSocketController.Factory outputSocketControllerFactory, + final ExceptionWitnessResponderButton.Factory exceptionWitnessResponderButtonFactory, + @Assisted final HttpSource source) { + super(eventBus, outputSocketControllerFactory, exceptionWitnessResponderButtonFactory, source); + } + +} diff --git a/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceControllerFactory.java b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceControllerFactory.java index 0845fd318a..16d01908aa 100644 --- a/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceControllerFactory.java +++ b/ui/src/main/java/edu/wpi/grip/ui/pipeline/source/SourceControllerFactory.java @@ -2,6 +2,7 @@ import edu.wpi.grip.core.Source; import edu.wpi.grip.core.sources.CameraSource; +import edu.wpi.grip.core.sources.HttpSource; import edu.wpi.grip.core.sources.MultiImageFileSource; import com.google.inject.Inject; @@ -17,6 +18,8 @@ public class SourceControllerFactory { @Inject private MultiImageFileSourceController.Factory multiImageFileSourceControllerFactory; @Inject + private HttpSourceController.Factory httpSourceControllerFactory; + @Inject private SourceController.BaseSourceControllerFactory baseSourceControllerFactory; SourceControllerFactory() { /* no-op */ } @@ -36,6 +39,9 @@ public SourceController create(S source) { } else if (source instanceof MultiImageFileSource) { sourceController = (SourceController) multiImageFileSourceControllerFactory.create( (MultiImageFileSource) source); + } else if (source instanceof HttpSource) { + sourceController = (SourceController) httpSourceControllerFactory.create( + (HttpSource) source); } else { sourceController = (SourceController) baseSourceControllerFactory.create(source); } diff --git a/ui/src/test/java/edu/wpi/grip/ui/pipeline/AddSourceViewTest.java b/ui/src/test/java/edu/wpi/grip/ui/pipeline/AddSourceViewTest.java index f5915df8c7..74c93fb8ce 100644 --- a/ui/src/test/java/edu/wpi/grip/ui/pipeline/AddSourceViewTest.java +++ b/ui/src/test/java/edu/wpi/grip/ui/pipeline/AddSourceViewTest.java @@ -42,7 +42,7 @@ public void start(Stage stage) { this.eventBus = new EventBus("Test Event Bus"); this.mockCameraSourceFactory = new MockCameraSourceFactory(eventBus); - addSourceView = new AddSourceView(eventBus, null, null, mockCameraSourceFactory); + addSourceView = new AddSourceView(eventBus, null, null, mockCameraSourceFactory, null); final Scene scene = new Scene(addSourceView, 800, 600); stage.setScene(scene); @@ -131,7 +131,7 @@ public void start(Stage stage) { this.eventBus = new EventBus("Test Event Bus"); this.mockCameraSourceFactory = new MockCameraSourceFactory(eventBus); - addSourceView = new AddSourceView(eventBus, null, null, mockCameraSourceFactory); + addSourceView = new AddSourceView(eventBus, null, null, mockCameraSourceFactory, null); final Scene scene = new Scene(addSourceView, 800, 600); stage.setScene(scene);