diff --git a/README.md b/README.md index 2ba69568..394fe535 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## webrepl -A quick and dirty REPL for JPv3's IIIF Cookbook recipes. +A quick and dirty, single-threaded REPL for JPv3's IIIF Cookbook recipes. ### How to Build diff --git a/src/main/java/info/freelibrary/iiif/webrepl/DiagConsumer.java b/src/main/java/info/freelibrary/iiif/webrepl/DiagConsumer.java index 24022ac5..86a330d1 100644 --- a/src/main/java/info/freelibrary/iiif/webrepl/DiagConsumer.java +++ b/src/main/java/info/freelibrary/iiif/webrepl/DiagConsumer.java @@ -71,7 +71,7 @@ public void accept(final Diag aDiagnostic) { } // Add line numbers to what's returned - code = StringUtils.addLineNumbers(myBuffer.toString()); + code = StringUtils.addLineNumbers(myBuffer.toString().trim()); // Zero out the output buffer myBuffer.setLength(0); diff --git a/src/main/java/info/freelibrary/iiif/webrepl/Server.java b/src/main/java/info/freelibrary/iiif/webrepl/Server.java index 2355f716..3d976546 100644 --- a/src/main/java/info/freelibrary/iiif/webrepl/Server.java +++ b/src/main/java/info/freelibrary/iiif/webrepl/Server.java @@ -4,11 +4,6 @@ import static info.freelibrary.iiif.webrepl.Status.METHOD_NOT_ALLOWED; import static info.freelibrary.iiif.webrepl.Status.NOT_FOUND; import static info.freelibrary.iiif.webrepl.Status.OK; -import static info.freelibrary.util.Constants.EMPTY; -import static info.freelibrary.util.Constants.EOL; -import static info.freelibrary.util.Constants.INADDR_ANY; -import static info.freelibrary.util.Constants.SPACE; -import static java.nio.charset.StandardCharsets.UTF_8; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; @@ -17,11 +12,13 @@ import java.io.PrintStream; import java.net.URISyntaxException; import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.CodeSource; import java.time.Duration; import java.util.List; +import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -33,6 +30,7 @@ import org.microhttp.Request; import org.microhttp.Response; +import info.freelibrary.util.Constants; import info.freelibrary.util.Env; import info.freelibrary.util.StringUtils; import info.freelibrary.util.warnings.PMD; @@ -41,7 +39,6 @@ import info.freelibrary.iiif.presentation.v3.Manifest; import jdk.jshell.JShell; -import jdk.jshell.JShellException; import jdk.jshell.Snippet; import jdk.jshell.Snippet.Status; import jdk.jshell.SnippetEvent; @@ -97,7 +94,7 @@ public Server(final Handler aHandler) throws IOException { * * @throws InterruptedException If the application's process is interrupted */ - public void run() throws InterruptedException { + public void start() throws InterruptedException { myEventLoop.join(); } @@ -119,7 +116,7 @@ Options getOptions() { final int bufSize = Env.get(Config.READ_BUFFER_SIZE, DEFAULT_READ_BUF_SIZE); final long timeout = (long) Env.get(Config.REQUEST_TIMEOUT, DEFAULT_REQ_TIMEOUT); - return Options.builder().withHost(INADDR_ANY).withPort(port).withMaxRequestSize(reqSize) + return Options.builder().withHost(Constants.INADDR_ANY).withPort(port).withMaxRequestSize(reqSize) .withRequestTimeout(Duration.ofSeconds(timeout)).withReadBufferSize(bufSize).build(); } @@ -135,7 +132,7 @@ Options getOptions() { @SuppressWarnings({ "checkstyle:UncommentedMain" }) public static void main(final String[] anArgsArray) throws IOException, InterruptedException, URISyntaxException, ClassNotFoundException { - new Server().run(); + new Server().start(); } /** @@ -158,6 +155,9 @@ static class JPv3Handler implements Handler { /** A hard-coded snippet that will return the result of the supplied code snippet. */ private static final String MAIN_METHOD = "Jpv3Snippet.main(new String[]{});"; + /** A constant for the status response. */ + private static final String STATUS_LABEL = "Status: "; + /** The response headers that are returned for plain text responses. */ private static final List
TEXT_CONTENT_TYPE = getHeaders(new Header(CONTENT_TYPE, "text/plain")); @@ -177,100 +177,35 @@ static class JPv3Handler implements Handler { * @throws ClassNotFoundException If the JPv3 classes cannot be found * @throws URISyntaxException If the Jar file's path couldn't be converted into a URI */ - @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR }) + @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR, PMD.SYSTEM_PRINTLN }) JPv3Handler() throws IOException, ClassNotFoundException, URISyntaxException { myOutputStream = new ByteArrayOutputStream(); + + // Create the shell, specifying preview features and a version that we depend on myShell = JShell.builder().compilerOptions("--enable-preview", "--source", "21") .out(new PrintStream(myOutputStream)).build(); // Pre-load and cache the code editor's HTML page myHTML = new WebResource("index.html").getBytes(); - // Check that all the imports can be loaded successfully + // Check that all the imports can be loaded successfully (i.e., that our classpath is current) myShell.eval(getImports()).stream().filter(event -> !Snippet.Status.VALID.equals(event.status())) .map((Function) SnippetEvent::status).forEach(System.err::println); + // Just add a new line to distinguish between the startup import load and what follows + System.err.println(); + // Load the JPv3 classes so the imports have something to load myShell.addToClasspath(getJarClasspath()); } @Override - @SuppressWarnings({ PMD.COGNITIVE_COMPLEXITY, PMD.SYSTEM_PRINTLN, Sonar.COGNITIVE_COMPLEXITY, - Sonar.SYSTEM_OUT_ERR }) public void handle(final Request aRequest, final Consumer aCallback) { - final String uri = aRequest.uri(); - final Response response; - - switch (aRequest.method()) { - case "POST" -> { - System.err.println(aRequest.method() + SPACE + uri); - - if (uri.endsWith("submit") || uri.endsWith("submit/")) { - final StringBuilder submission = new StringBuilder(); - - submission.append(decodeSubmission(aRequest.body())); - System.err.println("body: " + submission.toString()); - - try { - myShell.eval(getCode(submission.toString().trim())).forEach(event -> { - switch (event.status()) { - case VALID -> { - // The submitted code is valid, so get its result to write out - final List results = myShell.eval(MAIN_METHOD); - - results.forEach(output -> { - if (Snippet.Status.VALID.equals(output.status())) { - System.out.println(output.value()); - } - }); - } - case REJECTED -> { - final Snippet snippet = event.snippet(); - final StringBuilder buffer = new StringBuilder(snippet.source()); - - // Just check one at a time, and let the editor iterate - myShell.diagnostics(snippet).findFirst().ifPresentOrElse( - new DiagConsumer(buffer), () -> buffer.delete(0, buffer.length()) - .append("Parsing error, but diagnostics were not found")); - - try { - myOutputStream.write(buffer.toString().getBytes(UTF_8)); - } catch (final IOException details) { - System.err.println(details); - } - } - default -> { - final JShellException exception = event.exception(); - - if (exception != null) { - System.err.println(exception); - System.out.println(exception); - } - } - } - }); - } catch (final IOException details) { - System.err.println(details); - System.out.println(details); - } - - response = getResponse(OK, TEXT_CONTENT_TYPE, myOutputStream.toString().getBytes(UTF_8)); - myOutputStream.reset(); // After writing it to the browser, zero out its contents - } else { - response = getResponse(NOT_FOUND, TEXT_CONTENT_TYPE, EMPTY_BODY); - } - } - case "GET" -> { - System.err.println(aRequest.method() + SPACE + uri); - - if (uri.endsWith("editor") || uri.endsWith("editor/")) { - response = getResponse(OK, HTML_CONTENT_TYPE, myHTML); - } else { - response = getResponse(NOT_FOUND, TEXT_CONTENT_TYPE, EMPTY_BODY); - } - } - default -> response = getResponse(METHOD_NOT_ALLOWED, TEXT_CONTENT_TYPE, EMPTY_BODY); - } + final Response response = switch (aRequest.method()) { + case "POST" -> handlePost(aRequest); + case "GET" -> handleGet(aRequest); + default -> getResponse(METHOD_NOT_ALLOWED, TEXT_CONTENT_TYPE, EMPTY_BODY); + }; aCallback.accept(response); } @@ -282,14 +217,14 @@ public void handle(final Request aRequest, final Consumer aCallback) { * @return A decoded code submission */ private String decodeSubmission(final byte[] aSubmission) { - final String data = new String(aSubmission, UTF_8); + final String data = new String(aSubmission, StandardCharsets.UTF_8); if (data.startsWith(CODE_DELIM)) { - return URLDecoder.decode(data.substring(CODE_DELIM.length()), UTF_8); + return URLDecoder.decode(data.substring(CODE_DELIM.length()), StandardCharsets.UTF_8); } // If submission wasn't valid, just return an empty string which will evaluate to nothing - return EMPTY; + return Constants.EMPTY; } /** @@ -299,19 +234,31 @@ private String decodeSubmission(final byte[] aSubmission) { * @return The formatted code * @throws IOException If there is trouble reading the imports for the code block */ + @SuppressWarnings({ Sonar.SYSTEM_OUT_ERR, PMD.SYSTEM_PRINTLN }) private String getCode(final String aCodeBlock) throws IOException { final String code = """ {} - class Jpv3Snippet { - public static void main(String[] args) { + class Jpv3Snippet { + public static void main(String[] args) { {} - - } + } } """; - return StringUtils.format(code, getImports(aCodeBlock), aCodeBlock); + return StringUtils.format(code, getImports(aCodeBlock), indent(aCodeBlock, 4)); + } + + /** + * Gets a default error message to use when there are no diagnostics to help decipher why a code snippet failed. + * + * @param aSource The source code that failed to be evaluated + * @return A default error message + */ + private String getDefaultErrorMsg(final String aSource) { + return new StringBuilder() + .append("Parsing error, but there wasn't a useful diagnostic message. Submitted source code: ") + .append(Constants.EOL).append(Constants.EOL).append(aSource).toString(); } /** @@ -344,9 +291,16 @@ private String getImports(final String aSnippet) throws IOException { final String imports = reader.lines().filter(line -> !line.isBlank()).filter(line -> { final String className = line.substring(line.lastIndexOf('.') + 1, line.length() - 1); return aSnippet == null || aSnippet.contains(className); - }).collect(Collectors.joining(EOL)) + EOL; + }).collect(Collectors.joining(Constants.EOL)).trim(); + + // The above gets imports for the user, the below shows the imported imports in the logs + if (imports.length() > 0) { + System.err.println(Constants.EOL + "Imports:"); + System.err.println(indent(StringUtils.addLineNumbers(imports), 2)); + } else { + System.err.println(); + } - System.err.println(imports); return imports; } } @@ -381,6 +335,151 @@ private Response getResponse(final info.freelibrary.iiif.webrepl.Status aEnum, f return new Response(aEnum.getCode(), aEnum.getMessage(), aHeaderList, aBody); } + /** + * Handle a GET request. + * + * @param aRequest A GET request to handle + * @return A response to the GET request + */ + @SuppressWarnings({ PMD.SYSTEM_PRINTLN, Sonar.SYSTEM_OUT_ERR }) + private Response handleGet(final Request aRequest) { + final String uri = aRequest.uri(); + + System.err.println(aRequest.method() + Constants.SPACE + uri + Constants.EOL); + + if (uri.endsWith("editor") || uri.endsWith("editor/")) { + return getResponse(OK, HTML_CONTENT_TYPE, myHTML); + } + + return getResponse(NOT_FOUND, TEXT_CONTENT_TYPE, EMPTY_BODY); + } + + /** + * Handle a POST request. + * + * @param aRequest A POST request to handle + * @return A response to the POST request + */ + @SuppressWarnings({ PMD.COGNITIVE_COMPLEXITY, Sonar.COGNITIVE_COMPLEXITY, PMD.SYSTEM_PRINTLN, + Sonar.SYSTEM_OUT_ERR }) + private Response handlePost(final Request aRequest) { + final String uri = aRequest.uri(); + final byte[] bytes; + final String body; + + System.err.println(aRequest.method() + Constants.SPACE + uri); + + // If we're not processing a code submission, we don't care + if (!uri.endsWith("submit") && !uri.endsWith("submit/")) { + return getResponse(NOT_FOUND, TEXT_CONTENT_TYPE, EMPTY_BODY); + } + + body = decodeSubmission(aRequest.body()).trim(); + + System.err.println("Body: " + Constants.EOL + indent(StringUtils.addLineNumbers(body), 2)); + + try { + myShell.eval(getCode(body)).forEach(event -> { + final Snippet.Status status = event.status(); + + switch (status) { + case VALID -> { + System.err.println(Constants.EOL + STATUS_LABEL + status); + + // The submitted code is valid, so get its result to write out + final List results = myShell.eval(MAIN_METHOD); + + results.forEach(output -> { + if (Snippet.Status.VALID.equals(output.status())) { + System.out.println(output.value()); + } else { + System.err.println(output.snippet()); + System.err.println("VALID to " + output.status()); + } + }); + } + case REJECTED, RECOVERABLE_DEFINED -> { + final Snippet snippet = event.snippet(); + final StringBuilder buffer = new StringBuilder(snippet.source()); + + // Just check one at a time, and let the editor iterate + myShell.diagnostics(snippet).findFirst().ifPresentOrElse(new DiagConsumer(buffer), + () -> buffer.delete(0, buffer.length()) + .append(getDefaultErrorMsg(StringUtils.addLineNumbers(body)))); + try { + final String output = buffer.toString().trim(); + + // Write correctly formatted code to the user + myOutputStream.write(output.getBytes(StandardCharsets.UTF_8)); + + // Wrap the user's response in another format + System.err.println("Error: "); + System.err.println(indent(reformat(output), 2)); + System.err.println(Constants.EOL + STATUS_LABEL + status + Constants.EOL); + } catch (final IOException details) { + System.err.println(details); + } finally { + try { + myOutputStream.flush(); + } catch (final IOException details) { + System.err.println(details); + } + } + } + default -> { + System.err.println("Unhandled status: " + status); + + Optional.ofNullable(event.exception()).ifPresentOrElse(exception -> { + System.err.println(exception); + System.out.println(exception); + }, () -> { + final String error = getDefaultErrorMsg(body); + + System.err.println(error); + System.out.println(error); + }); + } + } + }); + + // Clear out our snippets for a fresh start with the next request + myShell.snippets().forEach(snippet -> { + myShell.drop(snippet); + }); + } catch (final IOException details) { + System.err.println(details); + System.out.println(details); + } + + bytes = myOutputStream.toString().trim().getBytes(StandardCharsets.UTF_8); + myOutputStream.reset(); + + return getResponse(OK, TEXT_CONTENT_TYPE, bytes); + } + + /** + * Indent a supplied string by the supplied number of spaces. + * + * @param aInput A string with lines to indent + * @param anIndentation A number of spaces to indent + * @return An indented string + */ + private String indent(final String aInput, final int anIndentation) { + final String indent = Constants.SPACE.repeat(anIndentation); + return aInput.lines().map(line -> indent + line).collect(Collectors.joining(Constants.EOL)); + } + + /** + * Strips line numbers from a user error message's code block so that they can be re-added across the whole + * message. + * + * @param aMessage An error message that's being returned to the user + * @return An error message that has reformatted what was returned to the user + */ + private String reformat(final String aMessage) { + return StringUtils.addLineNumbers(aMessage.replaceAll("(?m)^\\d+\\s+(?=[A-Za-z])", Constants.EMPTY)); + } + /** * Creates a list of Header(s) from the supplied differentiating header. The other headers added by this method * are related to CORS support. diff --git a/src/test/java/info/freelibrary/iiif/webrepl/ServerTest.java b/src/test/java/info/freelibrary/iiif/webrepl/ServerTest.java index c9a8f808..0052888c 100644 --- a/src/test/java/info/freelibrary/iiif/webrepl/ServerTest.java +++ b/src/test/java/info/freelibrary/iiif/webrepl/ServerTest.java @@ -26,6 +26,7 @@ import org.mockito.Mockito; import info.freelibrary.util.warnings.JDK; + import info.freelibrary.iiif.webrepl.Server.JPv3Handler; /** @@ -64,7 +65,7 @@ void testHandlePostSubmitValidCode() throws URISyntaxException, ClassNotFoundExc assertEquals(201, response.status()); assertEquals("OK", response.reason()); assertEquals("text/plain", response.headers().get(0).value()); - assertEquals("Hello, World!\n", new String(response.body())); + assertEquals("Hello, World!", new String(response.body())); } /** @@ -154,7 +155,7 @@ final void testServer() throws InterruptedException, ClassNotFoundException, URI final Runnable task = () -> { try { - server.run(); + server.start(); } catch (final InterruptedException details) { Thread.currentThread().interrupt(); fail(details.getMessage(), details);