diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/GraderTools.java b/grader/src/main/java/edu/pdx/cs/joy/grader/GraderTools.java index d34cb2ec3..cd220da46 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/GraderTools.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/GraderTools.java @@ -4,6 +4,7 @@ import ch.qos.logback.classic.Logger; import com.google.common.annotations.VisibleForTesting; import edu.pdx.cs.joy.grader.canvas.CompareCanvasAndWebsiteSchedules; +import edu.pdx.cs.joy.grader.canvas.ExportCanvasSurveyResponses; import edu.pdx.cs.joy.grader.canvas.GradesFromCanvasImporter; import edu.pdx.cs.joy.grader.gradebook.ui.GradeBookGUI; import edu.pdx.cs.joy.grader.poa.ui.PlanOfAttackGrader; @@ -87,6 +88,9 @@ private static Class getToolClass(String tool) { case "compareCanvasAndWebsiteSchedules": return CompareCanvasAndWebsiteSchedules.class; + case "exportCanvasSurveyResponses": + return ExportCanvasSurveyResponses.class; + default: usage("Unknown tool: " + tool); return null; @@ -123,6 +127,7 @@ private static void usage(String message) { err.println(" findUngradedSubmissions List submissions that need to be tested or graded"); err.println(" compareCanvasAndWebsiteSchedules"); err.println(" Compare assignment due dates in Canvas and the website schedule"); + err.println(" exportCanvasSurveyResponses Export End of Term Survey responses from a Canvas course"); err.println(" toolArg A command line argument to send to the tool"); err.println(); diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java new file mode 100644 index 000000000..44a2ce38b --- /dev/null +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponses.java @@ -0,0 +1,473 @@ +package edu.pdx.cs.joy.grader.canvas; + +import com.google.common.annotations.VisibleForTesting; +import com.opencsv.CSVReader; +import com.opencsv.exceptions.CsvValidationException; +import jakarta.json.Json; +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.PrintStream; +import java.io.StringReader; +import java.io.Writer; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Exports the responses to the End of Term Survey from a Canvas Classic Quiz + * as anonymized HTML. + */ +public class ExportCanvasSurveyResponses { + static final URI DEFAULT_CANVAS_BASE_URI = URI.create("https://canvas.pdx.edu"); + static final String SURVEY_TITLE = "End of Term Survey"; + + private static final Logger logger = LoggerFactory.getLogger("edu.pdx.cs.joy.grader"); + private static final String PAGE_TITLE = "Previously on The Joy of Coding..."; + private static final String INTRODUCTION = "Here are some comments from students who have taken The Joy of Coding."; + private static final String PREPARATION_QUESTION = "How well prepared were you for the work in this class?"; + private static final List PREPARATION_RESPONSES = + List.of("very good", "good", "fair", "poor", "very poor"); + private static final String XHTML_DOCTYPE = + ""; + private static final Pattern NEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>;\\s*rel=\"next\""); + private static final Pattern QUESTION_COLUMN_PATTERN = Pattern.compile("^\\d+:\\s+(.+)$"); + private static final Pattern TERM_NAME_PATTERN = Pattern.compile("^(Winter|Spring|Summer|Fall) (\\d{4})$"); + private static final String CONSENT_QUESTION = + "May I use your answers to these questions (not your name) todescribe this course in the future?"; + private static final Duration REPORT_POLL_DELAY = Duration.ofSeconds(1); + + private final HttpClient httpClient; + private final URI canvasBaseUri; + + public ExportCanvasSurveyResponses() { + this(HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(), DEFAULT_CANVAS_BASE_URI); + } + + @VisibleForTesting + ExportCanvasSurveyResponses(HttpClient httpClient, URI canvasBaseUri) { + this.httpClient = httpClient; + this.canvasBaseUri = canvasBaseUri; + } + + public static void main(String[] args) throws IOException, InterruptedException { + try { + new ExportCanvasSurveyResponses().run(args); + + } catch (IllegalArgumentException | IllegalStateException ex) { + usage(ex.getMessage()); + } + } + + @VisibleForTesting + void run(String[] args) throws IOException, InterruptedException { + if (args.length == 0) { + throw new IllegalArgumentException("Missing Canvas API token file name"); + } + if (args.length == 1) { + throw new IllegalArgumentException("Missing Canvas course ID"); + } + if (args.length > 2) { + throw new IllegalArgumentException("Extraneous command line argument: " + args[2]); + } + + String apiToken = readApiToken(Path.of(args[0])); + int courseId = parseCourseId(args[1]); + + export(apiToken, courseId, Path.of(".")); + } + + @VisibleForTesting + void export(String apiToken, int courseId, Path outputDirectory) throws IOException, InterruptedException { + logger.info("Retrieving the Canvas term for course " + courseId); + Path outputFile = outputDirectory.resolve(outputFileName(getCourseTermName(apiToken, courseId))); + logger.info("Finding the \"" + SURVEY_TITLE + "\" quiz"); + CanvasQuiz quiz = findClassicSurvey(apiToken, courseId); + String reportCsv = downloadStudentAnalysisReport(apiToken, courseId, quiz.id()); + Map> responses = parseSurveyResponses(reportCsv); + logger.info("Writing anonymized survey responses to " + outputFile); + writeHtml(outputFile, responses); + } + + private String getCourseTermName(String apiToken, int courseId) throws IOException, InterruptedException { + URI courseUri = this.canvasBaseUri.resolve("/api/v1/courses/" + courseId + "?include%5B%5D=term"); + JsonObject course = getJsonObject(apiToken, courseUri); + JsonObject term = course.getJsonObject("term"); + if (term == null || term.getJsonString("name") == null) { + throw new IOException("Canvas course " + courseId + " has no term name"); + } + return term.getString("name"); + } + + private static String outputFileName(String termName) { + Matcher matcher = TERM_NAME_PATTERN.matcher(termName); + if (!matcher.matches()) { + throw new IllegalArgumentException("Canvas course term \"" + termName + "\" is not a season and year"); + } + return "comments-" + matcher.group(1).toLowerCase() + matcher.group(2) + ".html"; + } + + private CanvasQuiz findClassicSurvey(String apiToken, int courseId) throws IOException, InterruptedException { + List matches = new ArrayList<>(); + URI nextPage = this.canvasBaseUri.resolve("/api/v1/courses/" + courseId + "/quizzes?per_page=100"); + + while (nextPage != null) { + HttpResponse response = invokeCanvas(nextPage, apiToken, HttpRequest.BodyPublishers.noBody()); + for (CanvasQuiz quiz : parseQuizzes(response.body())) { + if (SURVEY_TITLE.equals(quiz.title())) { + matches.add(quiz); + } + } + nextPage = getNextPage(response.headers()); + } + + if (matches.isEmpty()) { + throw new IllegalStateException("Canvas course " + courseId + " has no quiz named \"" + SURVEY_TITLE + "\""); + } + if (matches.size() > 1) { + throw new IllegalStateException("Canvas course " + courseId + " has multiple quizzes named \"" + SURVEY_TITLE + "\""); + } + + CanvasQuiz survey = matches.get(0); + if (survey.quizType() == null) { + throw new IllegalStateException("\"" + SURVEY_TITLE + "\" is not a Classic Quiz"); + } + return survey; + } + + private String downloadStudentAnalysisReport(String apiToken, int courseId, int quizId) throws IOException, InterruptedException { + URI reportsUri = this.canvasBaseUri.resolve("/api/v1/courses/" + courseId + "/quizzes/" + quizId + "/reports"); + String body = """ + {"quiz_report":{"report_type":"student_analysis","includes_all_versions":true}} + """; + logger.info("Requesting the Canvas student analysis report"); + HttpResponse response = invokeCanvas(reportsUri, apiToken, HttpRequest.BodyPublishers.ofString(body)); + JsonObject report = parseObject(response.body()); + + JsonString progressUrl = report.getJsonString("progress_url"); + JsonString reportUrl = report.getJsonString("url"); + if (progressUrl == null || reportUrl == null) { + throw new IOException("Canvas did not return a report progress URL"); + } + + logger.info("Waiting for Canvas to generate the student analysis report"); + waitForReport(apiToken, URI.create(progressUrl.getString())); + JsonObject completedReport = getJsonObject(apiToken, URI.create(reportUrl.getString())); + JsonObject file = completedReport.getJsonObject("file"); + if (file == null || file.getJsonString("url") == null) { + throw new IOException("Canvas did not return a generated report file"); + } + + logger.info("Downloading the student analysis report"); + return downloadReportFile(apiToken, URI.create(file.getString("url"))); + } + + private String downloadReportFile(String apiToken, URI fileUrl) throws IOException, InterruptedException { + URI currentUrl = fileUrl; + boolean includeAuthorization = true; + + while (true) { + HttpResponse response = sendRequest(currentUrl, apiToken, HttpRequest.BodyPublishers.noBody(), includeAuthorization); + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return response.body(); + } + if (response.statusCode() < 300 || response.statusCode() >= 400) { + throw new IOException("Canvas report download from " + currentUrl + " failed with status code " + response.statusCode()); + } + + String location = response.headers().firstValue("Location") + .orElseThrow(() -> new IOException("Canvas report download redirected without a Location header")); + currentUrl = currentUrl.resolve(location); + includeAuthorization = isCanvasUri(currentUrl); + } + } + + private void waitForReport(String apiToken, URI progressUrl) throws IOException, InterruptedException { + while (true) { + JsonObject progress = getJsonObject(apiToken, progressUrl); + String workflowState = progress.getString("workflow_state", ""); + if ("completed".equals(workflowState)) { + return; + } + if ("failed".equals(workflowState)) { + throw new IOException("Canvas failed to generate the student analysis report"); + } + + Thread.sleep(REPORT_POLL_DELAY); + } + } + + private JsonObject getJsonObject(String apiToken, URI uri) throws IOException, InterruptedException { + HttpResponse response = invokeCanvas(uri, apiToken, HttpRequest.BodyPublishers.noBody()); + return parseObject(response.body()); + } + + private HttpResponse invokeCanvas(URI uri, String apiToken, HttpRequest.BodyPublisher body) + throws IOException, InterruptedException { + if (!isCanvasUri(uri)) { + throw new IllegalArgumentException("Canvas API returned an unexpected URI: " + uri); + } + + HttpResponse response = sendRequest(uri, apiToken, body, true); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Canvas request to " + uri + " failed with status code " + response.statusCode()); + } + return response; + } + + private HttpResponse sendRequest(URI uri, String apiToken, HttpRequest.BodyPublisher body, boolean includeAuthorization) + throws IOException, InterruptedException { + HttpRequest.Builder request = HttpRequest.newBuilder(uri) + .header("Accept", "application/json"); + if (includeAuthorization) { + request.header("Authorization", "Bearer " + apiToken); + } + if (body.contentLength() == 0) { + request.GET(); + } else { + request.header("Content-Type", "application/json").POST(body); + } + + HttpResponse response = this.httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString()); + return response; + } + + private boolean isCanvasUri(URI uri) { + return this.canvasBaseUri.getScheme().equalsIgnoreCase(uri.getScheme()) + && this.canvasBaseUri.getHost().equalsIgnoreCase(uri.getHost()) + && this.canvasBaseUri.getPort() == uri.getPort(); + } + + @VisibleForTesting + static Map> parseSurveyResponses(String csv) throws IOException { + try (CSVReader reader = new CSVReader(new StringReader(csv))) { + String[] header = reader.readNext(); + if (header == null) { + throw new IOException("Canvas student analysis report is empty"); + } + + Map questionColumns = findQuestionColumns(header); + int consentColumn = findConsentColumn(header); + Map> responses = new LinkedHashMap<>(); + questionColumns.values().forEach(question -> responses.putIfAbsent(question, new ArrayList<>())); + + String[] row; + while ((row = reader.readNext()) != null) { + if (!hasGivenConsent(row, consentColumn)) { + continue; + } + + for (Map.Entry questionColumn : questionColumns.entrySet()) { + int column = questionColumn.getKey(); + if (column < row.length && !row[column].isBlank()) { + responses.get(questionColumn.getValue()).add(row[column]); + } + } + } + return responses; + + } catch (CsvValidationException ex) { + throw new IOException("While parsing the Canvas student analysis report", ex); + } + } + + private static Map findQuestionColumns(String[] header) { + Map questionColumns = new LinkedHashMap<>(); + for (int column = 0; column < header.length; column++) { + Matcher matcher = QUESTION_COLUMN_PATTERN.matcher(header[column]); + if (matcher.matches() && !CONSENT_QUESTION.equals(matcher.group(1))) { + questionColumns.put(column, matcher.group(1)); + } + } + if (questionColumns.isEmpty()) { + throw new IllegalArgumentException("Canvas student analysis report has no question columns"); + } + return questionColumns; + } + + private static int findConsentColumn(String[] header) { + for (int column = 0; column < header.length; column++) { + Matcher matcher = QUESTION_COLUMN_PATTERN.matcher(header[column]); + if (matcher.matches() && CONSENT_QUESTION.equals(matcher.group(1))) { + return column; + } + } + + throw new IllegalArgumentException("Canvas student analysis report has no consent question"); + } + + private static boolean hasGivenConsent(String[] row, int consentColumn) { + return consentColumn < row.length && "yes".equalsIgnoreCase(row[consentColumn].trim()); + } + + private static List parseQuizzes(String json) { + List quizzes = new ArrayList<>(); + try (JsonReader reader = Json.createReader(new StringReader(json))) { + JsonArray values = reader.readArray(); + for (JsonValue value : values) { + if (value.getValueType() != JsonValue.ValueType.OBJECT) { + continue; + } + JsonObject quiz = value.asJsonObject(); + JsonString title = quiz.getJsonString("title"); + if (title != null && quiz.containsKey("id")) { + quizzes.add(new CanvasQuiz(quiz.getInt("id"), title.getString(), quiz.getString("quiz_type", null))); + } + } + } + return quizzes; + } + + private static JsonObject parseObject(String json) { + try (JsonReader reader = Json.createReader(new StringReader(json))) { + return reader.readObject(); + } + } + + private URI getNextPage(HttpHeaders headers) { + for (String linkHeader : headers.allValues("Link")) { + Matcher matcher = NEXT_LINK_PATTERN.matcher(linkHeader); + if (matcher.find()) { + return URI.create(matcher.group(1)); + } + } + return null; + } + + private static String readApiToken(Path apiTokenFile) throws IOException { + if (!Files.exists(apiTokenFile)) { + throw new IllegalArgumentException("Canvas API token file \"" + apiTokenFile + "\" does not exist"); + } + + String apiToken = Files.readString(apiTokenFile).trim(); + if (apiToken.isEmpty()) { + throw new IllegalArgumentException("Canvas API token file \"" + apiTokenFile + "\" is empty"); + } + return apiToken; + } + + private static int parseCourseId(String courseIdText) { + try { + return Integer.parseInt(courseIdText); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Canvas course ID \"" + courseIdText + "\" is not an integer"); + } + } + + private static void writeHtml(Path outputFile, Map> responses) throws IOException { + Path parent = outputFile.toAbsolutePath().getParent(); + if (parent != null && !Files.exists(parent)) { + throw new IllegalArgumentException("Parent directory \"" + parent + "\" does not exist"); + } + + try (Writer writer = Files.newBufferedWriter(outputFile, StandardCharsets.UTF_8)) { + writer.write(XHTML_DOCTYPE + "\n"); + writer.write("\n"); + writer.write(" \n"); + writer.write(" "); + writeEscapedHtml(writer, PAGE_TITLE); + writer.write("\n"); + writer.write(" \n"); + writer.write(" \n"); + writer.write("

"); + writeEscapedHtml(writer, PAGE_TITLE); + writer.write("

\n"); + writer.write("

"); + writeEscapedHtml(writer, INTRODUCTION); + writer.write("

\n"); + writer.write("
    \n"); + + for (Map.Entry> question : responses.entrySet()) { + writer.write("
  1. "); + writeEscapedHtml(writer, question.getKey()); + writer.write("
  2. \n"); + + if (PREPARATION_QUESTION.equals(question.getKey())) { + writeResponseCounts(writer, question.getValue()); + + } else { + writer.write("
      \n"); + for (String answer : question.getValue()) { + writer.write("
    • "); + writeEscapedHtml(writer, answer); + writer.write("
    • \n"); + } + writer.write("
    \n"); + } + } + writer.write("
\n"); + writer.write(" \n"); + writer.write("\n"); + } + } + + private static void writeResponseCounts(Writer writer, List responses) throws IOException { + Map counts = new LinkedHashMap<>(); + PREPARATION_RESPONSES.forEach(response -> counts.put(response, 0)); + for (String response : responses) { + if (counts.containsKey(response)) { + counts.merge(response, 1, Integer::sum); + } + } + + writer.write(" \n"); + writer.write(" \n"); + for (Map.Entry count : counts.entrySet()) { + writer.write(" \n"); + } + writer.write("
ResponseStudents
"); + writeEscapedHtml(writer, count.getKey()); + writer.write(""); + writer.write(String.valueOf(count.getValue())); + writer.write("
\n"); + } + + private static void writeEscapedHtml(Writer writer, String text) throws IOException { + for (int i = 0; i < text.length(); i++) { + switch (text.charAt(i)) { + case '&' -> writer.write("&"); + case '<' -> writer.write("<"); + case '>' -> writer.write(">"); + case '"' -> writer.write("""); + case '\'' -> writer.write("'"); + default -> writer.write(text.charAt(i)); + } + } + } + + private static void usage(String message) { + PrintStream err = System.err; + err.println("+++ " + message); + err.println(); + err.println("usage: java ExportCanvasSurveyResponses apiTokenFileName courseId"); + err.println(" apiTokenFileName File containing the Canvas API token"); + err.println(" courseId Canvas ID of the course offering"); + err.println(""); + err.println("Writes comments-seasonyear.html for the course's Canvas term"); + err.println(); + err.println("Exports the \"" + SURVEY_TITLE + "\" Classic Quiz from Canvas as anonymized HTML"); + err.println(); + System.exit(1); + } + + private record CanvasQuiz(int id, String title, String quizType) { + } +} diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java new file mode 100644 index 000000000..21e06072a --- /dev/null +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/canvas/ExportCanvasSurveyResponsesTest.java @@ -0,0 +1,147 @@ +package edu.pdx.cs.joy.grader.canvas; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ExportCanvasSurveyResponsesTest { + + @Test + void exportsAnonymizedResponsesFromClassicQuizStudentAnalysisReport(@TempDir File tempDir) + throws IOException, InterruptedException { + List authorizationHeaders = new ArrayList<>(); + List redirectedAuthorizationHeaders = new ArrayList<>(); + HttpServer fileServer = HttpServer.create(new InetSocketAddress(0), 0); + URI fileServerUri = URI.create("http://localhost:" + fileServer.getAddress().getPort()); + fileServer.createContext("/files/student-analysis.csv", exchange -> { + try { + redirectedAuthorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, 200, """ + name,id,section,section_id,submitted,attempt,100: How well prepared were you for the work in this class?,0.5,101: What should future students know?,0.5,102: May I use your answers to these questions (not your name) todescribe this course in the future?,0.0,n correct,n incorrect,score + Student One,1,001,10,2026-08-10,1,very good,0.5,Use & write tests,0.5,Yes,0.0,2,0,1 + Student Two,2,001,10,2026-08-10,1,good,0.5,Another useful response,0.5,Yes,0.0,2,0,1 + Student Three,3,001,10,2026-08-10,1,very good,0.5,This answer must not be included,0.5,No,0.0,1,1,0.5 + """); + } finally { + exchange.close(); + } + }); + fileServer.start(); + + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + URI canvasBaseUri = URI.create("http://localhost:" + server.getAddress().getPort()); + server.createContext("/", exchange -> { + try { + authorizationHeaders.add(exchange.getRequestHeaders().getFirst("Authorization")); + respondToCanvasRequest(exchange, canvasBaseUri, fileServerUri); + } finally { + exchange.close(); + } + }); + server.start(); + + try { + ExportCanvasSurveyResponses exporter = new ExportCanvasSurveyResponses(HttpClient.newHttpClient(), canvasBaseUri); + exporter.export("canvas-token", 42, tempDir.toPath()); + + String html = Files.readString(new File(tempDir, "comments-summer2026.html").toPath()); + assertThat(html, containsString("Previously on The Joy of Coding...")); + assertThat(html, containsString("

Previously on The Joy of Coding...

")); + assertThat(html, containsString("

Here are some comments from students who have taken The Joy of Coding.

")); + assertThat(html, containsString("ResponseStudents")); + assertThat(html, containsString("very good1")); + assertThat(html, containsString("good1")); + assertThat(html, containsString("fair0")); + assertThat(html, containsString("poor0")); + assertThat(html, containsString("very poor0")); + assertThat(html, containsString("What should future students know?")); + assertThat(html, containsString("Use <generics> & write tests")); + assertThat(html, not(containsString("This answer must not be included"))); + assertThat(html, not(containsString("May I use your answers to these questions"))); + assertThat(html, not(containsString("Yes"))); + assertThat(html, not(containsString("Student One"))); + assertThat(html, not(containsString("student.one@example.com"))); + + assertEquals(7, authorizationHeaders.size()); + authorizationHeaders.forEach(header -> assertEquals("Bearer canvas-token", header)); + assertEquals(1, redirectedAuthorizationHeaders.size()); + assertEquals(null, redirectedAuthorizationHeaders.get(0)); + } finally { + server.stop(0); + fileServer.stop(0); + } + } + + private static void respondToCanvasRequest(HttpExchange exchange, URI canvasBaseUri, URI fileServerUri) throws IOException { + String path = exchange.getRequestURI().getPath(); + if (path.equals("/api/v1/courses/42")) { + respond(exchange, 200, """ + {"term": {"name": "Summer 2026"}} + """); + + } else if (path.equals("/api/v1/courses/42/quizzes") && "page=2".equals(exchange.getRequestURI().getQuery())) { + respond(exchange, 200, """ + [{"id": 9, "title": "End of Term Survey", "quiz_type": "assignment"}] + """); + + } else if (path.equals("/api/v1/courses/42/quizzes")) { + exchange.getResponseHeaders().add("Link", + "<" + canvasBaseUri + "/api/v1/courses/42/quizzes?page=2>; rel=\"next\""); + respond(exchange, 200, """ + [{"id": 8, "title": "Other Quiz", "quiz_type": "assignment"}] + """); + + } else if (path.equals("/api/v1/courses/42/quizzes/9/reports") && "POST".equals(exchange.getRequestMethod())) { + String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + assertThat(requestBody, containsString("\"report_type\":\"student_analysis\"")); + assertThat(requestBody, containsString("\"includes_all_versions\":true")); + respond(exchange, 201, """ + { + "progress_url": "%s/progress/4", + "url": "%s/api/v1/courses/42/quizzes/9/reports/7" + } + """.formatted(canvasBaseUri, canvasBaseUri)); + + } else if (path.equals("/progress/4")) { + respond(exchange, 200, """ + {"workflow_state": "completed"} + """); + + } else if (path.equals("/api/v1/courses/42/quizzes/9/reports/7")) { + respond(exchange, 200, """ + {"file": {"url": "%s/files/student-analysis.csv"}} + """.formatted(canvasBaseUri)); + + } else if (path.equals("/files/student-analysis.csv")) { + exchange.getResponseHeaders().add("Location", fileServerUri + "/files/student-analysis.csv"); + exchange.sendResponseHeaders(302, -1); + + } else { + respond(exchange, 404, "{}"); + } + } + + private static void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + exchange.getResponseBody().write(bytes); + } +}