diff --git a/.github/workflows/deploy-jshell-aws.yaml b/.github/workflows/deploy-jshell-aws.yaml new file mode 100644 index 0000000000..4e88489ba3 --- /dev/null +++ b/.github/workflows/deploy-jshell-aws.yaml @@ -0,0 +1,44 @@ +name: deploy +on: + push: + branches: [ master ] + paths: + - 'jshell-aws-backend/**' + workflow_dispatch: + +permissions: + id-token: write + contents: read + +jobs: + deploy: + name: Deploy to AWS + runs-on: ubuntu-latest + env: + AWS_REGION: eu-west-2 + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Authenticate with AWS + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.GH_ACTIONS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Install SAM CLI + uses: aws-actions/setup-sam@v2 + with: + use-installer: true + + - name: Install Java + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'corretto' + + - name: Build Application + run: sam build -t infrastructure/template.yaml + + - name: Deploy to AWS + run: sam deploy --no-confirm-changeset --no-fail-on-empty-changeset --config-file infrastructure/samconfig.toml diff --git a/application/config.json.template b/application/config.json.template index a1aec8f470..7c391a7282 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -1,4 +1,5 @@ { + "jshellAwsApiUrl": "", "token": "", "githubApiKey": "", "databasePath": "local-database.db", diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index e819f8e7d1..1799825163 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -16,6 +16,7 @@ * Configuration of the application. Create instances using {@link #load(Path)}. */ public final class Config { + private final String jShellAwsApiUrl; private final String token; private final String githubApiKey; private final String databasePath; @@ -49,7 +50,8 @@ public final class Config { @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) - private Config(@JsonProperty(value = "token", required = true) String token, + private Config(@JsonProperty(value = "jshellAwsApiUrl", required = true) String jShellAwsApiUrl, + @JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "githubApiKey", required = true) String githubApiKey, @JsonProperty(value = "databasePath", required = true) String databasePath, @JsonProperty(value = "projectWebsite", required = true) String projectWebsite, @@ -95,6 +97,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", required = true) String selectRolesChannelPattern) { + this.jShellAwsApiUrl = Objects.requireNonNull(jShellAwsApiUrl); this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -418,4 +421,8 @@ public String getMemberCountCategoryPattern() { public RSSFeedsConfig getRSSFeedsConfig() { return rssFeedsConfig; } + + public String getjShellAwsApiUrl() { + return jShellAwsApiUrl; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 893adbc00f..b821cfd13b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -36,6 +36,8 @@ import org.togetherjava.tjbot.features.javamail.RSSHandlerRoutine; import org.togetherjava.tjbot.features.jshell.JShellCommand; import org.togetherjava.tjbot.features.jshell.JShellEval; +import org.togetherjava.tjbot.features.jshell.aws.JShellAWSCommand; +import org.togetherjava.tjbot.features.jshell.aws.JShellService; import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; @@ -192,6 +194,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new JShellAWSCommand(new JShellService(config.getjShellAwsApiUrl()))); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellAWSCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellAWSCommand.java new file mode 100644 index 0000000000..3530929922 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellAWSCommand.java @@ -0,0 +1,199 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.jshell.aws.exceptions.JShellAPIException; + +import java.awt.Color; + +/** + * This class contains the complete logic for the /jshell-aws command. + * + * @author Suraj Kumar + */ +public class JShellAWSCommand extends SlashCommandAdapter { + private static final Logger logger = LogManager.getLogger(JShellAWSCommand.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String CODE_PARAMETER = "code"; + private final JShellService jShellService; + + /** + * Constructs a new JShellAWSCommand + * + * @param jShellService The service class to make requests against AWS + */ + public JShellAWSCommand(JShellService jShellService) { + super("jshell-aws", "Execute Java code in Discord!", CommandVisibility.GUILD); + getData().addOption(OptionType.STRING, CODE_PARAMETER, "The code to execute using JShell", + true); + this.jShellService = jShellService; + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + Member member = event.getMember(); + + if (member == null) { + event.reply("Member that executed the command is no longer available, won't execute") + .queue(); + return; + } + + logger.info("JShell AWS invoked by {} in channel {}", member.getAsMention(), + event.getChannelId()); + + OptionMapping input = event.getOption(CODE_PARAMETER); + + if (input == null || input.getAsString().isEmpty()) { + EmbedBuilder eb = new EmbedBuilder(); + eb.setDescription(member.getAsMention() + + ", you forgot to provide the code for JShell to evaluate or it was too short!\nTry running the command again and make sure to select the code option"); + eb.setColor(Color.ORANGE); + event.replyEmbeds(eb.build()).queue(); + return; + } + + event.deferReply().queue(); + + InteractionHook hook = event.getHook(); + + String code = input.getAsString(); + + try { + respondWithJShellOutput(hook, jShellService.sendRequest(new JShellRequest(code)), code); + } catch (JShellAPIException jShellAPIException) { + handleJShellAPIException(hook, jShellAPIException, member, code); + } catch (Exception e) { + logger.error( + "An error occurred while sending/receiving request from the AWS JShell API", e); + respondWithSevereAPIError(hook, code); + } + } + + private static void handleJShellAPIException(InteractionHook hook, + JShellAPIException jShellAPIException, Member member, String code) { + switch (jShellAPIException.getStatusCode()) { + case 400 -> { + logger.warn("HTTP 400 error occurred with the JShell AWS API {}", + jShellAPIException.getBody()); + respondWithInputError(hook, jShellAPIException.getBody()); + } + case 408 -> respondWithTimeout(hook, member, code); + default -> { + logger.error("HTTP {} received from JShell AWS API {}", + jShellAPIException.getStatusCode(), jShellAPIException.getBody()); + respondWithSevereAPIError(hook, code); + } + } + } + + private static void respondWithJShellOutput(InteractionHook hook, JShellResponse response, + String code) { + // Extracted as fields to be compliant with Sonar + final String SNIPPET_SECTION_TITLE = "## Snippets\n"; + final String BACKTICK = "`"; + final String NEWLINE = "\n"; + final String DOUBLE_NEWLINE = "\n\n"; + final String STATUS = "**Status**: "; + final String OUTPUT_SECTION_TITLE = "**Output**\n"; + final String JAVA_CODE_BLOCK_START = "```java\n"; + final String CODE_BLOCK_END = "```\n"; + final String DIAGNOSTICS_SECTION_TITLE = "**Diagnostics**\n"; + final String CONSOLE_OUTPUT_SECTION_TITLE = "## Console Output\n"; + final String ERROR_OUTPUT_SECTION_TITLE = "## Error Output\n"; + + StringBuilder sb = new StringBuilder(); + sb.append(SNIPPET_SECTION_TITLE); + + for (JShellSnippet snippet : response.events()) { + sb.append(BACKTICK); + sb.append(snippet.statement()); + sb.append(BACKTICK).append(DOUBLE_NEWLINE); + sb.append(STATUS); + sb.append(snippet.status()); + sb.append(NEWLINE); + + if (snippet.value() != null && !snippet.value().isEmpty()) { + sb.append(OUTPUT_SECTION_TITLE); + sb.append(JAVA_CODE_BLOCK_START); + sb.append(snippet.value()); + sb.append(CODE_BLOCK_END); + } + + if (!snippet.diagnostics().isEmpty()) { + sb.append(DIAGNOSTICS_SECTION_TITLE); + for (String diagnostic : snippet.diagnostics()) { + sb.append(BACKTICK).append(diagnostic).append(BACKTICK).append(NEWLINE); + } + } + } + + if (response.outputStream() != null && !response.outputStream().isEmpty()) { + sb.append(CONSOLE_OUTPUT_SECTION_TITLE); + sb.append(JAVA_CODE_BLOCK_START); + sb.append(response.outputStream()); + sb.append(CODE_BLOCK_END); + } + + if (response.errorStream() != null && !response.errorStream().isEmpty()) { + sb.append(ERROR_OUTPUT_SECTION_TITLE); + sb.append(JAVA_CODE_BLOCK_START); + sb.append(response.errorStream()); + sb.append(CODE_BLOCK_END); + } + + String description; + if (sb.length() > 4000) { + description = sb.substring(0, 500) + "...``` truncated " + (sb.length() - 500) + + " characters"; + } else { + description = sb.toString(); + } + + sendEmbed(hook, description, Color.GREEN, code); + } + + private static void respondWithInputError(InteractionHook hook, String response) { + JShellErrorResponse errorResponse; + try { + errorResponse = OBJECT_MAPPER.readValue(response, JShellErrorResponse.class); + } catch (JsonProcessingException e) { + errorResponse = new JShellErrorResponse( + "There was a problem with the input you provided, please check and try again"); + } + EmbedBuilder eb = new EmbedBuilder(); + eb.setDescription(errorResponse.error()); + eb.setColor(Color.ORANGE); + hook.editOriginalEmbeds(eb.build()).queue(); + } + + private static void respondWithTimeout(InteractionHook hook, Member member, String code) { + sendEmbed(hook, member.getAsMention() + + " the code you provided took too long and the request has timed out! Consider tweaking your code to run a little faster.", + Color.ORANGE, code); + } + + private static void respondWithSevereAPIError(InteractionHook hook, String code) { + sendEmbed(hook, "An internal error occurred, please try again later", Color.RED, code); + } + + private static void sendEmbed(InteractionHook hook, String description, Color color, + String code) { + EmbedBuilder eb = new EmbedBuilder(); + eb.setDescription(description); + eb.setColor(color); + eb.setFooter("Code that was executed:\n" + code); + hook.editOriginalEmbeds(eb.build()).queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellErrorResponse.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellErrorResponse.java new file mode 100644 index 0000000000..4b37699ea0 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellErrorResponse.java @@ -0,0 +1,13 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a response from JShell that contains an error key. + * + * @author Suraj Kuamr + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record JShellErrorResponse(@JsonProperty("error") String error) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellRequest.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellRequest.java new file mode 100644 index 0000000000..b127d86dcd --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellRequest.java @@ -0,0 +1,11 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +/** + * A record containing the code snippet to be evaluated by the AWS JShell API + * + * @param code The Java code snippet to execute + * + * @author Suraj Kumar + */ +public record JShellRequest(String code) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellResponse.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellResponse.java new file mode 100644 index 0000000000..4497d80c7c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellResponse.java @@ -0,0 +1,21 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * A record containing the AWS JShell API response. + * + * @param errorStream The content in JShells error stream + * @param outputStream The content in JShells standard output stream + * @param events A list of snippets that were evaluated + * + * @author Suraj Kumar + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record JShellResponse(@JsonProperty("errorStream") String errorStream, + @JsonProperty("outputStream") String outputStream, + @JsonProperty("events") List events) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellService.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellService.java new file mode 100644 index 0000000000..d3425002ca --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellService.java @@ -0,0 +1,79 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.togetherjava.tjbot.features.jshell.aws.exceptions.JShellAPIException; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * The JShellService class is used to interact with the AWS JShell API. + * + * @author Suraj Kumar + */ +public class JShellService { + private static final Logger LOGGER = LogManager.getLogger(JShellService.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final String apiURL; + private final HttpClient httpClient; + + /** + * Constructs a JShellService. + * + * @param apiURl The Lambda Function URL to send API requests to + */ + public JShellService(String apiURl) { + this.apiURL = apiURl; + this.httpClient = HttpClient.newHttpClient(); + } + + /** + * Sends an HTTP request to the AWS JShell API. + * + * @param jShellRequest The request object containing the code to evaluate + * @return The API response as a JShellResponse object + * @throws URISyntaxException If the API URL is invalid + * @throws JsonProcessingException If the API response failed to get parsed by Jackson to our + * mapping. + */ + public JShellResponse sendRequest(JShellRequest jShellRequest) + throws URISyntaxException, JsonProcessingException { + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(apiURL)) + .header("Content-Type", "application/json") + .timeout(Duration.of(30, TimeUnit.SECONDS.toChronoUnit())) + .POST(HttpRequest.BodyPublishers + .ofString(OBJECT_MAPPER.writeValueAsString(jShellRequest))) + .build(); + + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new JShellAPIException(response.statusCode(), response.body()); + } + + String body = response.body(); + LOGGER.trace("Received the following body from the AWS JShell API: {}", body); + + return OBJECT_MAPPER.readValue(response.body(), JShellResponse.class); + + } catch (IOException | InterruptedException e) { + LOGGER.error("Failed to send http request to the AWS JShell API", e); + Thread.currentThread().interrupt(); + } + + return null; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellSnippet.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellSnippet.java new file mode 100644 index 0000000000..01b0f36cfc --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellSnippet.java @@ -0,0 +1,23 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * A JShell snippet is a statement that is to be executed. This record is used to hold information + * about a statement that was provided by the AWS JShell API + * + * @param statement The statement that was executed + * @param value The return value of the statement + * @param status The status from evaluating the statement e.g. "VALID", "INVALID" + * @param diagnostics A list of diagnostics such as error messages provided by JShell + * + * @author Suraj Kumar + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record JShellSnippet(@JsonProperty("statement") String statement, + @JsonProperty("value") String value, @JsonProperty("status") String status, + List diagnostics) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/exceptions/JShellAPIException.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/exceptions/JShellAPIException.java new file mode 100644 index 0000000000..75035ea17a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/exceptions/JShellAPIException.java @@ -0,0 +1,25 @@ +package org.togetherjava.tjbot.features.jshell.aws.exceptions; + +/** + * An exception that contains the HTTP status code and response body when the request to the JShell + * AWS API fails. + * + * @author Suraj Kumar + */ +public class JShellAPIException extends RuntimeException { + private final int statusCode; + private final String body; + + public JShellAPIException(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } + + public int getStatusCode() { + return statusCode; + } + + public String getBody() { + return body; + } +} diff --git a/jshell-aws-backend/README.md b/jshell-aws-backend/README.md new file mode 100644 index 0000000000..6fbe1df30b --- /dev/null +++ b/jshell-aws-backend/README.md @@ -0,0 +1,58 @@ +# JShell AWS Backend +This module contains the infrastructure and code to create an AWS Lambda hosted JShell API. +The API is used to evaluate Java code in AWS using JShell. + +Sample request that can be made to this API: + +```curl +curl -X POST "https://.lambda-url.>.on.aws/" \ + -H "Content-Type: application/json" \ + -d '{"code": "System.out.println(\"Hello, World\");"}' +``` + +## Getting Started +To use this project in your own AWS account, you first need an AWS account and an [AWS authenticated CLI](https://docs.aws.amazon.com/cli/v1/userguide/cli-chap-authentication.html). + +### Required CLI tools +* [AWS ClI](https://aws.amazon.com/cli/) +* [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) +* [cfn-lint](https://github.com/aws-cloudformation/cfn-lint) (this is for CloudFormation linting) + +Once your terminal is all setup and authenticated, the easiest way to deploy this application is by running the `./deploy.sh`. +This will compile and upload the project to AWS, it will then create a stack called "jshell" in your AWS account. The Lambda URL will be printed to the console. + +If you prefer to deploy without the help of the `./deploy.sh` you can do it manually: +1. `cd infrastructure` +2. `sam build` +3. `sam deploy` + +To delete the stack, this can be done either in CloudFormation via the AWS website or using the CLI command: +```bash +aws cloudformation delete-stack --stack-name jshell +``` + +## Testing Locally +To test the Lambda locally without the need to use an AWS account you can use the SAM Local to invoke the Lambda directly. + +First build the project: +1. `cd infrastructure` +2. `sam build` + +Now you can directly send events to the Lambda e.g. +```bash +echo '{"body": "{\"code\": \"System.out.println(\\\"Hello, World!\\\");\"}"}' | sam local invoke "CodeRunnerFunction" -e - +``` +We pass `body` in this request unlike how we do in AWS because we need to match how AWS would send the request to the function. + +**Note:** This requires [Docker](https://www.docker.com/) to be installed on your machine. + +If you want to test locally using a web server (e.g. testing the integration with TJ Bot) you can run the `start-local.sh` + +This will spin up a web server locally. To test you can use the following cURL: +```curl +curl -X POST http://127.0.0.1:3000/jshell \ + -H "Content-Type: application/json" \ + -d '{"code": "System.out.println(\"Hello, World!\");"}' +``` +**Note:** This is using the SAM CLI web server, and it can be very slow to serve requests. +This also requires [Docker](https://www.docker.com/) to be installed on your machine. diff --git a/jshell-aws-backend/build.gradle b/jshell-aws-backend/build.gradle new file mode 100644 index 0000000000..3a758c3c14 --- /dev/null +++ b/jshell-aws-backend/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'java' +} + +group = 'org.togetherjava' +version = '1.0' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + implementation 'com.amazonaws:aws-lambda-java-events:3.13.0' + + implementation 'com.fasterxml.jackson.core:jackson-core:2.17.2' + implementation 'com.fasterxml.jackson.core:jackson-annotations:2.17.2' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + + implementation 'org.apache.logging.log4j:log4j-api:2.23.1' + implementation 'org.apache.logging.log4j:log4j-core:2.23.1' + + testImplementation platform('org.junit:junit-bom:5.10.3') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.mockito:mockito-core:5.12.0' +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } +} + +sourceSets { + test { + java { + srcDirs 'src/test/integration', 'src/test/unit' + } + } +} \ No newline at end of file diff --git a/jshell-aws-backend/deploy.sh b/jshell-aws-backend/deploy.sh new file mode 100644 index 0000000000..2489895000 --- /dev/null +++ b/jshell-aws-backend/deploy.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +cfn-lint -t infrastructure/template.yaml +sam validate -t infrastructure/template.yaml --region eu-west-2 +sam validate -t infrastructure/template.yaml --lint +sam build -t infrastructure/template.yaml --parallel + +sam deploy --stack-name "jshell" \ + --no-fail-on-empty-changeset \ + --no-confirm-changeset \ + --resolve-s3 \ + --s3-prefix "jshell" \ + --region "${AWS_REGION:-eu-west-2}" \ + --capabilities CAPABILITY_IAM \ + --template infrastructure/template.yaml diff --git a/jshell-aws-backend/infrastructure/samconfig.toml b/jshell-aws-backend/infrastructure/samconfig.toml new file mode 100644 index 0000000000..91894a1280 --- /dev/null +++ b/jshell-aws-backend/infrastructure/samconfig.toml @@ -0,0 +1,8 @@ +version = 0.1 +[default.deploy.parameters] +stack_name = "jshell" +resolve_s3 = true +s3_prefix = "jshell" +region = "eu-west-2" +capabilities = "CAPABILITY_IAM" +image_repositories = [] diff --git a/jshell-aws-backend/infrastructure/template.yaml b/jshell-aws-backend/infrastructure/template.yaml new file mode 100644 index 0000000000..c6975a3f21 --- /dev/null +++ b/jshell-aws-backend/infrastructure/template.yaml @@ -0,0 +1,47 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: JShell Stack +Transform: [ AWS::LanguageExtensions, AWS::Serverless-2016-10-31 ] + +Globals: + Function: + Timeout: 20 + CodeUri: .. + MemorySize: 512 + Runtime: java21 + Architectures: [ arm64 ] + +Resources: + CodeRunnerFunction: + Type: AWS::Serverless::Function + Properties: + Handler: org.togetherjava.jshell.CodeRunner::handleRequest + SnapStart: + ApplyOn: PublishedVersions + AutoPublishAlias: live + FunctionUrlConfig: + AuthType: NONE + Cors: + AllowOrigins: + - '*' + Policies: + - AWSLambdaBasicExecutionRole + - Statement: + Effect: Allow + Action: lambda:InvokeFunctionUrl + Resource: '*' + Events: + Api: + Type: Api + Properties: + Path: /jshell + Method: POST + + CodeRunnerFunctionLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub /aws/lambda/${CodeRunnerFunction} + RetentionInDays: 7 + +Outputs: + LambdaURL: + Value: !GetAtt CodeRunnerFunctionUrl.FunctionUrl diff --git a/jshell-aws-backend/openapi_spec.yaml b/jshell-aws-backend/openapi_spec.yaml new file mode 100644 index 0000000000..d5b500048f --- /dev/null +++ b/jshell-aws-backend/openapi_spec.yaml @@ -0,0 +1,96 @@ +openapi: 3.0.1 +info: + title: "JShell API" + version: "1.0" + +servers: + - url: https://33ykaxnvin5lusynogiwrevqgm0ovara.lambda-url.eu-west-2.on.aws/ + description: AWS + - url: http://127.0.0.1:3000/jshell + description: localhost + +paths: + /: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + example: "System.out.println(\"Hello, World!\");" + responses: + 200: + description: "Successful invocation of the JShell API" + content: + application/json: + examples: + Happy path: + value: + errorStream: "" + outputStream: "Hello, World!" + events: [ + statement: "System.out.println(\"Hello, World!\");", + value: "", + status: "VALID" + ] + Syntax errors: + value: + errorStream: "" + outputStream: "" + events: [ + statement: "System.out.prinn(\"Hello, World!\");", + value: null, + status: "REJECTED" + ] + System.err.println: + value: + errorStream: "Hello, World!" + outputStream: "" + events: [ + statement: "System.out.prinn(\"Hello, World!\");", + value: "", + status: "VALID" + ] + Simple evaluation that provides a value: + value: + errorStream: "" + outputStream: "" + events: [ + statement: "1+1", + value: "2", + status: "VALID" + ] + 408: + description: "Execution took too long so it timed out" + content: + application/json: + examples: + Response: + value: + error: "Execution timed out" + 400: + description: "Missing fields or the provided input is incorrect" + content: + application/json: + examples: + Invalid request payload: + value: + error: "Invalid input format" + Invalid code value: + value: + error: "Code field is empty or invalid" + 500: + description: "Internal server error" + content: + application/json: + examples: + JShell exceptions: + value: + error: "Error during code execution" + InputStream error: + value: + error: "Failed to read the request stream" \ No newline at end of file diff --git a/jshell-aws-backend/src/main/java/org/togetherjava/jshell/CodeRunner.java b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/CodeRunner.java new file mode 100644 index 0000000000..361b712426 --- /dev/null +++ b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/CodeRunner.java @@ -0,0 +1,201 @@ +package org.togetherjava.jshell; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.togetherjava.jshell.exceptions.JShellEvaluationException; +import org.togetherjava.jshell.exceptions.JShellTimeoutException; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * This class is the main entry point used by AWS Lambda. When HTTP requests comes to AWS, this + * Lambda handler is invoked. The role of this command is to take the incoming request data and + * transform it into a suitable payload for the JShell Service then return the results as an + * appropriate JSON HTTP response. + *

+ * When a request is made to the API, the request JSON is expected to look similar to the following + * example: + * { "code": "System.out.println(\"Hello, World!\");" } + * + * + * @author Suraj Kumar + */ +@SuppressWarnings("unused") // suppressed as the usage is outside the application +public class CodeRunner + implements RequestHandler { + private static final Logger LOGGER = LogManager.getLogger(CodeRunner.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final JShellService JSHELL_SERVICE = new JShellService(); + + /** + * Represents the deserialized request body that came into this handler. + * + * @param code the "code" that is to be evaluated by JShell + */ + @JsonIgnoreProperties(ignoreUnknown = true) + record Request(@JsonProperty("code") String code) { + } + + /** + * This record represents the HTTP request that came into this Lambda. AWS provides additional + * information such as request headers, method etc. But it also provides AWS specific data that + * we don't want or need. Hence, why this record only contains the "body" that came with the + * request. + * + * @param body The body that came with the HTTP request + */ + @JsonIgnoreProperties(ignoreUnknown = true) + record Payload(@JsonProperty("body") String body) { + } + + /** + * This record is used to represent an error response sent as a response to the request made. + * + * @param error The error message to display + */ + record ErrorResponse(String error) { + } + + /** + * Handles incoming requests for the Lambda function. This method parses the request, sends the + * requested code to be evaluated by the JShell service, and returns the result back to the API + * caller. + * + * @param event The incoming event to this Lambda + * @param context An AWS Lambda context object for accessing metadata and environment details + * @return APIGatewayProxyResponseEvent which contains the HTTP status code and body + */ + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, + Context context) { + LOGGER.trace("Handling request for function: {}", context.getFunctionName()); + + String requestData = event.getBody(); + + if (requestData == null) { + return respondWith(400, + serializeObject(new ErrorResponse("Failed to read the request stream"))); + } + + Request request = parseRequest(requestData); + + if (request == null || request.code().isBlank()) { + return respondWith(400, + serializeObject(new ErrorResponse("Code field is empty or invalid"))); + } + + try { + JShellOutput jShellOutput = executeCode(request); + return respondWith(200, serializeObject(jShellOutput)); + } catch (JShellTimeoutException e) { + return respondWith(408, serializeObject(new ErrorResponse(e.getMessage()))); + } catch (JShellEvaluationException e) { + return respondWith(500, serializeObject(new ErrorResponse(e.getMessage()))); + } + } + + private static APIGatewayProxyResponseEvent respondWith(int statusCode, String body) { + APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); + response.setStatusCode(statusCode); + response.setBody(body); + response.setHeaders(Map.of("Content-Type", "application/json")); + return response; + } + + /** + * Reads the input stream and returns it as a String. + * + * @param input The InputStream to read from + * @return The request data as a String, or null if an error occurs + */ + private String readInputStream(InputStream input) { + try { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + LOGGER.error("Error reading input stream", e); + return null; + } + } + + /** + * Parses the request data into a Payload object. + * + * @param data The request data as a String + * @return The parsed Payload object, or null if parsing fails + */ + private Payload parsePayload(String data) { + try { + return OBJECT_MAPPER.readValue(data, Payload.class); + } catch (JsonProcessingException e) { + LOGGER.error("Error parsing request payload", e); + return null; + } + } + + /** + * Parses the Payload object to obtain the Request object. + * + * @param payload The Payload object + * @return The parsed Request object, or null if parsing fails + */ + private Request parseRequest(String payload) { + try { + return OBJECT_MAPPER.readValue(payload, Request.class); + } catch (JsonProcessingException e) { + LOGGER.error("Error parsing request body", e); + return null; + } + } + + /** + * Executes the provided code using the JShell service and returns the result. + * + * @param request The Request object containing the code to execute + * @return The result of the code execution as a JShellOutput object + */ + private JShellOutput executeCode(Request request) { + try { + CompletableFuture futureOutput = + JSHELL_SERVICE.executeJShellSnippet(request.code()); + return futureOutput.get(15L, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new JShellTimeoutException("JShell execution timed out"); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new JShellEvaluationException( + "Error executing JShell snippet: " + e.getMessage()); + } + } + + /** + * Converts the provided object into a JSON string. On serialization exceptions, this method + * will return an empty JSON string. + * + * @param object The object to convert to JSON + * @return A JSON String representing the provided object + */ + private static String serializeObject(Object object) { + try { + return OBJECT_MAPPER.writeValueAsString(object); + } catch (IOException e) { + LOGGER.error("Error serializing output", e); + return "{}"; // Return an empty JSON object in case of serialization failure + } + } +} diff --git a/jshell-aws-backend/src/main/java/org/togetherjava/jshell/EvaluatedSnippet.java b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/EvaluatedSnippet.java new file mode 100644 index 0000000000..0c2086cc03 --- /dev/null +++ b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/EvaluatedSnippet.java @@ -0,0 +1,18 @@ +package org.togetherjava.jshell; + +import java.util.List; + +/** + * A record containing the JShell snippet evaluation. A snippet in the context of JShell refers to a + * statement in Java. + * + * @param statement The statement that was executed + * @param status The status returned by JShell + * @param value The value returned for the executed snippet + * @param diagnostics The diagnostic provided by JShell, usually this contains errors such as syntax + * + * @author Suraj Kumar + */ +public record EvaluatedSnippet(String statement, String status, String value, + List diagnostics) { +} diff --git a/jshell-aws-backend/src/main/java/org/togetherjava/jshell/JShellOutput.java b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/JShellOutput.java new file mode 100644 index 0000000000..e9c58df04e --- /dev/null +++ b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/JShellOutput.java @@ -0,0 +1,20 @@ +package org.togetherjava.jshell; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * A record containing the entire JShell output after evaluating code that was provided. This + * contains the output of the standard output, error output and a list of all the snippets that were + * evaluated during the execution. + * + * @param outputStream The output that was provided to the standard output. + * @param errorStream The output that was provided to the error output. + * @param events All the snippets that were evaluated during the execution. + * + * @author Suraj Kumar + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record JShellOutput(String outputStream, String errorStream, List events) { +} diff --git a/jshell-aws-backend/src/main/java/org/togetherjava/jshell/JShellService.java b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/JShellService.java new file mode 100644 index 0000000000..06d04d408f --- /dev/null +++ b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/JShellService.java @@ -0,0 +1,107 @@ +package org.togetherjava.jshell; + +import jdk.jshell.JShell; +import jdk.jshell.SnippetEvent; +import jdk.jshell.SourceCodeAnalysis; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.togetherjava.jshell.exceptions.JShellEvaluationException; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; + +/** + * This class is used to interface with the JShell CLI. Its sole role is to interact with JShell and + * handle running commands and returning the output. + * + * @author Suraj Kumar + */ +public class JShellService { + private static final Logger LOGGER = LogManager.getLogger(JShellService.class); + + /** + * This method is used to run a snippet of Java code using JShell. The method runs async so the + * result is captured in a CompletableFuture containing the JShellOutput result. + * + * @param snippet The Java code that is to be evaluated by JShell. + * @return A Future containing the output of the evaluated code. + * @throws RuntimeException When there was an error running JShell or an exception occured + * during code evaluation. + */ + public CompletableFuture executeJShellSnippet(String snippet) + throws JShellEvaluationException { + return CompletableFuture.supplyAsync(() -> { + LOGGER.info("Running snippet {}", snippet); + + try (OutputStream outputStream = new ByteArrayOutputStream(); + OutputStream errorStream = new ByteArrayOutputStream(); + + JShell jshell = JShell.builder() + .out(new PrintStream(outputStream)) + .err(new PrintStream(errorStream)) + .build()) { + + + List events = new ArrayList<>(); + String next = snippet; + + while (!next.isEmpty()) { + SourceCodeAnalysis.CompletionInfo completionInfo = + jshell.sourceCodeAnalysis().analyzeCompletion(next); + LOGGER.debug("completionInfo: {}", completionInfo); + + List evalEvents = jshell.eval(completionInfo.source()); + LOGGER.debug("evalEvents: {}", evalEvents); + + for (SnippetEvent event : evalEvents) { + String statement = event.snippet() + .toString() + .substring(event.snippet().toString().indexOf("-") + 1) + .trim(); + String status = event.status().toString(); + String value = event.value(); + + List diagnostics = new ArrayList<>(); + + if (status.equals("REJECTED")) { + diagnostics.addAll(jshell.diagnostics(event.snippet()) + .map(diag -> clean(diag.getMessage(Locale.ENGLISH)) + .replace("\\", "\\\\") + .replace("\n", "\\n")) + .toList()); + events.add(new EvaluatedSnippet(statement, status, value, diagnostics)); + break; + } + + events.add(new EvaluatedSnippet(statement, status, value, diagnostics)); + LOGGER.debug("Added event: {}", event); + } + + next = completionInfo.remaining(); + } + + String output = clean(outputStream.toString()); + String error = clean(errorStream.toString()); + + LOGGER.debug("JShell output stream: {}", output); + LOGGER.debug("JShell error stream: {}", error); + + return new JShellOutput(output, error, events); + } catch (Exception e) { + LOGGER.error("Failure while running JShell Snippet", e); + throw new JShellEvaluationException(e.getMessage()); + } + }, Executors.newCachedThreadPool()); + } + + private static String clean(String input) { + return input.replace("\r", "").trim(); + } +} diff --git a/jshell-aws-backend/src/main/java/org/togetherjava/jshell/exceptions/JShellEvaluationException.java b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/exceptions/JShellEvaluationException.java new file mode 100644 index 0000000000..f635a8172f --- /dev/null +++ b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/exceptions/JShellEvaluationException.java @@ -0,0 +1,14 @@ +package org.togetherjava.jshell.exceptions; + +/** + * An exception that covers when code snippet evaluation fails. + * + * @author Suraj Kumar + */ +public class JShellEvaluationException extends RuntimeException { + + /** Constructs a JShellEvaluationException with a given message */ + public JShellEvaluationException(String message) { + super(message); + } +} diff --git a/jshell-aws-backend/src/main/java/org/togetherjava/jshell/exceptions/JShellTimeoutException.java b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/exceptions/JShellTimeoutException.java new file mode 100644 index 0000000000..863a6f6e26 --- /dev/null +++ b/jshell-aws-backend/src/main/java/org/togetherjava/jshell/exceptions/JShellTimeoutException.java @@ -0,0 +1,14 @@ +package org.togetherjava.jshell.exceptions; + +/** + * An exception that covers when JShell times out. + * + * @author Suraj Kumar + */ +public class JShellTimeoutException extends RuntimeException { + + /** Constructs a JShellTimeoutException with a given message */ + public JShellTimeoutException(String message) { + super(message); + } +} diff --git a/jshell-aws-backend/src/test/integration/org.togetherjava/jshell/CodeRunnerMockTest.java b/jshell-aws-backend/src/test/integration/org.togetherjava/jshell/CodeRunnerMockTest.java new file mode 100644 index 0000000000..e498120c83 --- /dev/null +++ b/jshell-aws-backend/src/test/integration/org.togetherjava/jshell/CodeRunnerMockTest.java @@ -0,0 +1,109 @@ +package org.togetherjava.jshell; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +/** + * Integration tests for the CodeRunner Lambda function. The way these tests work is by spinning up + * a "mock" web server that acts as if it was the AWS API Gateway. Then, our local web server + * creates a proxy to the Lambda handler and handles the request/response. + *

+ * These tests are primarily targeted at the web portion of the code runner. + * + * @author Suraj Kumar + */ +@Disabled // CodeQL can't run this test, but we have adequate unit tests +class CodeRunnerMockTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final int HTTP_PORT = 3001; + private static final String API_URL = "http://localhost:%d/".formatted(HTTP_PORT); + private final HttpClient httpClient = HttpClient.newHttpClient(); + + /** + * Create a mock web server to pass HTTP requests to the Lambda handler. + * + * @throws IOException When we fail to create the web server or the port is in use. + */ + @BeforeAll + static void setupServer() throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(HTTP_PORT), 0); + server.createContext("/", new HttpLambdaProxy()); + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + } + + /** + * HTTP handler class that takes incoming requests from the mock web server and passes it to the + * Lambda handler + */ + static class HttpLambdaProxy implements HttpHandler { + private final CodeRunner codeRunner = new CodeRunner(); + + @Override + public void handle(HttpExchange exchange) throws IOException { + OutputStream outputStream = exchange.getResponseBody(); + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + + event.setBody(new String(exchange.getRequestBody().readAllBytes())); + + APIGatewayProxyResponseEvent response = + codeRunner.handleRequest(event, mock(Context.class)); + + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(response.getStatusCode(), response.getBody().length()); + + outputStream.write(response.getBody().getBytes()); + outputStream.flush(); + outputStream.close(); + } + } + + /** + * The request to send to the CodeRunner in the structure that it expects + */ + record CodeRequest(String code) { + } + + @ParameterizedTest(name = "{index} => code={0}, expectedStatus={1}, expectedBody={2}") + @CsvSource({ + "System.out.println(\"hello\");, 200, '{\"outputStream\":\"hello\",\"errorStream\":\"\",\"events\":[{\"statement\":\"System.out.println(\\\"hello\\\");\",\"status\":\"VALID\",\"value\":\"\",\"diagnostics\":[]}]}'", + "Thread.sleep(15001);, 408, '{\"error\":\"JShell execution timed out\"}'", + "'', 400, '{\"error\":\"Code field is empty or invalid\"}'"}) + void shouldRespondCorrectlyBasedOnCode(String code, int expectedStatus, String expectedBody) + throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(API_URL)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers + .ofString(OBJECT_MAPPER.writeValueAsString(new CodeRequest(code)))) + .build(); + + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(expectedStatus, response.statusCode()); + assertEquals(expectedBody, response.body()); + } +} diff --git a/jshell-aws-backend/src/test/unit/org/togetherjava/jshell/CodeRunnerTest.java b/jshell-aws-backend/src/test/unit/org/togetherjava/jshell/CodeRunnerTest.java new file mode 100644 index 0000000000..a6168f0526 --- /dev/null +++ b/jshell-aws-backend/src/test/unit/org/togetherjava/jshell/CodeRunnerTest.java @@ -0,0 +1,147 @@ +package org.togetherjava.jshell; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +@Disabled +class CodeRunnerTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * The request to send to the CodeRunner in the structure that it expects + */ + record CodeRequest(String code) { + } + + @Test + void shouldEvalHelloWorld() throws Exception { + CodeRunner codeRunner = new CodeRunner(); + + String codeSnippet = OBJECT_MAPPER.writeValueAsString(new CodeRequest(""" + System.out.println("Hello, World!"); + """)); + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setBody(codeSnippet); + + APIGatewayProxyResponseEvent response = + codeRunner.handleRequest(event, mock(Context.class)); + + JShellOutput jShellOutput = OBJECT_MAPPER.readValue(response.getBody(), JShellOutput.class); + + assertEquals("Hello, World!", jShellOutput.outputStream()); + } + + @Test + void shouldHaveErrorOutput() throws Exception { + CodeRunner codeRunner = new CodeRunner(); + + String codeSnippet = OBJECT_MAPPER.writeValueAsString(new CodeRequest(""" + System.err.println("Hello, World!"); + """)); + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setBody(codeSnippet); + + APIGatewayProxyResponseEvent response = + codeRunner.handleRequest(event, mock(Context.class)); + + JShellOutput jShellOutput = OBJECT_MAPPER.readValue(response.getBody(), JShellOutput.class); + + assertEquals("Hello, World!", jShellOutput.errorStream()); + } + + @Test + void shouldHaveBothErrorAndStdOutput() throws Exception { + CodeRunner codeRunner = new CodeRunner(); + + String codeSnippet = OBJECT_MAPPER.writeValueAsString(new CodeRequest(""" + System.out.println("Foo"); + System.err.println("Bar"); + """)); + + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setBody(codeSnippet); + + APIGatewayProxyResponseEvent response = + codeRunner.handleRequest(event, mock(Context.class)); + + JShellOutput jShellOutput = OBJECT_MAPPER.readValue(response.getBody(), JShellOutput.class); + + assertEquals("Foo", jShellOutput.outputStream()); + assertEquals("Bar", jShellOutput.errorStream()); + } + + @Test + void shouldShowSyntaxErrorCause() throws Exception { + CodeRunner codeRunner = new CodeRunner(); + + String codeSnippet = OBJECT_MAPPER.writeValueAsString(new CodeRequest(""" + int x = y + """)); + + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setBody(codeSnippet); + + APIGatewayProxyResponseEvent response = + codeRunner.handleRequest(event, mock(Context.class)); + + JShellOutput jShellOutput = OBJECT_MAPPER.readValue(response.getBody(), JShellOutput.class); + + List evaluatedSnippets = jShellOutput.events(); + + if (!evaluatedSnippets.isEmpty()) { + List diagnostics = evaluatedSnippets.getFirst().diagnostics(); + if (!diagnostics.isEmpty()) { + assertEquals("cannot find symbol\\n symbol: variable y\\n location: class", + diagnostics.getFirst()); + } + assertEquals(1, diagnostics.size()); + } + + assertEquals(1, evaluatedSnippets.size()); + } + + @Test + void shouldShowBadOperandError() throws Exception { + CodeRunner codeRunner = new CodeRunner(); + + String codeSnippet = OBJECT_MAPPER.writeValueAsString(new CodeRequest(""" + String name = "dummy"; + name++; + """)); + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + event.setBody(codeSnippet); + + APIGatewayProxyResponseEvent response = + codeRunner.handleRequest(event, mock(Context.class)); + + JShellOutput jShellOutput = OBJECT_MAPPER.readValue(response.getBody(), JShellOutput.class); + + List evaluatedSnippets = jShellOutput.events(); + + if (evaluatedSnippets.size() > 1) { + List diagnostics = evaluatedSnippets.get(1).diagnostics(); + if (!diagnostics.isEmpty()) { + assertEquals("bad operand type java.lang.String for unary operator '++'", + diagnostics.getFirst()); + } + assertEquals(1, diagnostics.size()); + } + + assertEquals(2, evaluatedSnippets.size()); + } + +} diff --git a/jshell-aws-backend/start-local.sh b/jshell-aws-backend/start-local.sh new file mode 100644 index 0000000000..ba5166194a --- /dev/null +++ b/jshell-aws-backend/start-local.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +sam build -t infrastructure/template.yaml --parallel +sam local start-api diff --git a/settings.gradle b/settings.gradle index fcbfb6d3e2..2181c5df3c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,5 @@ include 'application' include 'database' include 'formatter' include 'utils' +include 'jshell-aws-backend' +