| 1 | package edu.ucsb.cs.scaffold.services; | |
| 2 | ||
| 3 | import java.util.ArrayList; | |
| 4 | import java.util.List; | |
| 5 | import java.util.Map; | |
| 6 | import org.springframework.beans.factory.annotation.Value; | |
| 7 | import org.springframework.core.ParameterizedTypeReference; | |
| 8 | import org.springframework.http.HttpEntity; | |
| 9 | import org.springframework.http.HttpHeaders; | |
| 10 | import org.springframework.http.HttpMethod; | |
| 11 | import org.springframework.http.ResponseEntity; | |
| 12 | import org.springframework.stereotype.Service; | |
| 13 | import org.springframework.web.client.RestTemplate; | |
| 14 | import org.springframework.web.util.UriComponentsBuilder; | |
| 15 | ||
| 16 | /** | |
| 17 | * Thin wrapper around the PrairieLearn REST API (see https://docs.prairielearn.com/api/), | |
| 18 | * authenticated per call with a user's PrairieLearn personal access token. Callers handle | |
| 19 | * PrairieLearn's HTTP errors (401 = bad token, 403/404 = unknown or inaccessible course instance), | |
| 20 | * which RestTemplate surfaces as HttpClientErrorException subclasses. | |
| 21 | */ | |
| 22 | @Service | |
| 23 | public class PrairieLearnService { | |
| 24 | ||
| 25 | /** | |
| 26 | * The parts of a course instance response this app uses; the numeric id is the crucial one, since | |
| 27 | * it cannot be obtained from the GitHub repo. | |
| 28 | */ | |
| 29 | public record CourseInstanceInfo(Long courseInstanceId, String longName, String shortName) {} | |
| 30 | ||
| 31 | private final RestTemplate restTemplate; | |
| 32 | private final String plApiBase; | |
| 33 | private final ApiRetryHelper retryHelper; | |
| 34 | ||
| 35 | public PrairieLearnService( | |
| 36 | RestTemplate restTemplate, | |
| 37 | @Value("${pl.api.base:https://us.prairielearn.com/pl/api/v1}") String plApiBase, | |
| 38 | @Value("${PL_SERVICE_RETRY_INITIAL_SLEEP_SECONDS:8}") long retryInitialSleepSeconds, | |
| 39 | @Value("${PL_SERVICE_RETRY_MAX:3}") int retryMax, | |
| 40 | @Value("${PL_SERVICE_RATE_LIMIT_SLEEP_INITIAL_MS:1000}") long rateLimitSleepInitialMs) { | |
| 41 | this.restTemplate = restTemplate; | |
| 42 | this.plApiBase = plApiBase; | |
| 43 | this.retryHelper = | |
| 44 | new ApiRetryHelper( | |
| 45 | "PrairieLearn", | |
| 46 | "PL_SERVICE_RATE_LIMIT_SLEEP_INITIAL_MS", | |
| 47 | retryInitialSleepSeconds, | |
| 48 | retryMax, | |
| 49 | rateLimitSleepInitialMs); | |
| 50 | } | |
| 51 | ||
| 52 | /** | |
| 53 | * Fetches one course instance by its numeric id, e.g. {@code /course_instances/213133}. Returns | |
| 54 | * null when the response has no usable body. | |
| 55 | * | |
| 56 | * @param instanceId PrairieLearn's numeric course instance id | |
| 57 | * @param token the user's PrairieLearn PAT (plaintext) | |
| 58 | */ | |
| 59 | public CourseInstanceInfo getCourseInstance(long instanceId, String token) { | |
| 60 | String url = | |
| 61 | UriComponentsBuilder.fromUriString(plApiBase) | |
| 62 | .pathSegment("course_instances", String.valueOf(instanceId)) | |
| 63 | .toUriString(); | |
| 64 | ||
| 65 | HttpHeaders headers = new HttpHeaders(); | |
| 66 |
1
1. getCourseInstance : removed call to org/springframework/http/HttpHeaders::set → KILLED |
headers.set("Private-Token", token); |
| 67 | ResponseEntity<Map<String, Object>> response = | |
| 68 | retryHelper.execute( | |
| 69 | "GET " + url, | |
| 70 | () -> | |
| 71 |
1
1. lambda$getCourseInstance$0 : replaced return value with null for edu/ucsb/cs/scaffold/services/PrairieLearnService::lambda$getCourseInstance$0 → KILLED |
restTemplate.exchange( |
| 72 | url, | |
| 73 | HttpMethod.GET, | |
| 74 | new HttpEntity<>(headers), | |
| 75 | new ParameterizedTypeReference<>() {})); | |
| 76 | ||
| 77 | Map<String, Object> body = response.getBody(); | |
| 78 |
2
1. getCourseInstance : negated conditional → KILLED 2. getCourseInstance : negated conditional → KILLED |
if (body == null || body.get("course_instance_id") == null) { |
| 79 | return null; | |
| 80 | } | |
| 81 |
1
1. getCourseInstance : replaced return value with null for edu/ucsb/cs/scaffold/services/PrairieLearnService::getCourseInstance → KILLED |
return new CourseInstanceInfo( |
| 82 | // PrairieLearn returns ids as JSON strings, e.g. "213133". | |
| 83 | Long.valueOf(String.valueOf(body.get("course_instance_id"))), | |
| 84 | (String) body.get("course_instance_long_name"), | |
| 85 | (String) body.get("course_instance_short_name")); | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * One assessment from the {@code /course_instances/{id}/assessments} response — the fields | |
| 90 | * PlAssessment stores (issue #71). {@code assessmentName} matches the assessment's directory name | |
| 91 | * in the GitHub repo (PlAssessment.name), which is how the two sources are joined. | |
| 92 | */ | |
| 93 | public record AssessmentInfo( | |
| 94 | Long assessmentId, | |
| 95 | String assessmentName, | |
| 96 | // alphanumeric, e.g. "2" or "1a" | |
| 97 | String assessmentNumber, | |
| 98 | Long assessmentOrderBy, | |
| 99 | String title, | |
| 100 | String assessmentSetAbbreviation, | |
| 101 | Integer assessmentSetNumber, | |
| 102 | String assessmentSetHeading, | |
| 103 | String assessmentSetColor) {} | |
| 104 | ||
| 105 | /** | |
| 106 | * Fetches all assessments of a course instance, e.g. {@code | |
| 107 | * /course_instances/213133/assessments}. Entries without an {@code assessment_id} are skipped. | |
| 108 | * | |
| 109 | * @param instanceId PrairieLearn's numeric course instance id | |
| 110 | * @param token the user's PrairieLearn PAT (plaintext) | |
| 111 | */ | |
| 112 | public List<AssessmentInfo> getAssessments(long instanceId, String token) { | |
| 113 | String url = | |
| 114 | UriComponentsBuilder.fromUriString(plApiBase) | |
| 115 | .pathSegment("course_instances", String.valueOf(instanceId), "assessments") | |
| 116 | .toUriString(); | |
| 117 | ||
| 118 | HttpHeaders headers = new HttpHeaders(); | |
| 119 |
1
1. getAssessments : removed call to org/springframework/http/HttpHeaders::set → KILLED |
headers.set("Private-Token", token); |
| 120 | ResponseEntity<List<Map<String, Object>>> response = | |
| 121 | retryHelper.execute( | |
| 122 | "GET " + url, | |
| 123 | () -> | |
| 124 |
1
1. lambda$getAssessments$1 : replaced return value with null for edu/ucsb/cs/scaffold/services/PrairieLearnService::lambda$getAssessments$1 → KILLED |
restTemplate.exchange( |
| 125 | url, | |
| 126 | HttpMethod.GET, | |
| 127 | new HttpEntity<>(headers), | |
| 128 | new ParameterizedTypeReference<>() {})); | |
| 129 | ||
| 130 | List<Map<String, Object>> body = response.getBody(); | |
| 131 |
1
1. getAssessments : negated conditional → KILLED |
if (body == null) { |
| 132 | return List.of(); | |
| 133 | } | |
| 134 | List<AssessmentInfo> assessments = new ArrayList<>(); | |
| 135 | for (Map<String, Object> item : body) { | |
| 136 |
1
1. getAssessments : negated conditional → KILLED |
if (item.get("assessment_id") == null) { |
| 137 | continue; | |
| 138 | } | |
| 139 | assessments.add( | |
| 140 | new AssessmentInfo( | |
| 141 | asLong(item.get("assessment_id")), | |
| 142 | (String) item.get("assessment_name"), | |
| 143 | asString(item.get("assessment_number")), | |
| 144 | asLong(item.get("assessment_order_by")), | |
| 145 | (String) item.get("title"), | |
| 146 | (String) item.get("assessment_set_abbreviation"), | |
| 147 | asInteger(item.get("assessment_set_number")), | |
| 148 | (String) item.get("assessment_set_heading"), | |
| 149 | (String) item.get("assessment_set_color"))); | |
| 150 | } | |
| 151 |
1
1. getAssessments : replaced return value with Collections.emptyList for edu/ucsb/cs/scaffold/services/PrairieLearnService::getAssessments → KILLED |
return assessments; |
| 152 | } | |
| 153 | ||
| 154 | // PrairieLearn is inconsistent about numbers: some come as JSON strings ("2690012"), some as | |
| 155 | // JSON numbers (6). Both parse via their string form; null stays null. | |
| 156 | private static Long asLong(Object value) { | |
| 157 |
2
1. asLong : negated conditional → KILLED 2. asLong : replaced Long return value with 0L for edu/ucsb/cs/scaffold/services/PrairieLearnService::asLong → KILLED |
return value == null ? null : Long.valueOf(String.valueOf(value)); |
| 158 | } | |
| 159 | ||
| 160 | private static Integer asInteger(Object value) { | |
| 161 |
2
1. asInteger : replaced Integer return value with 0 for edu/ucsb/cs/scaffold/services/PrairieLearnService::asInteger → KILLED 2. asInteger : negated conditional → KILLED |
return value == null ? null : Integer.valueOf(String.valueOf(value)); |
| 162 | } | |
| 163 | ||
| 164 | // assessment_number is alphanumeric ("1a") but other fields may arrive as JSON numbers, | |
| 165 | // so normalize through String.valueOf rather than casting. | |
| 166 | private static String asString(Object value) { | |
| 167 |
2
1. asString : negated conditional → KILLED 2. asString : replaced return value with "" for edu/ucsb/cs/scaffold/services/PrairieLearnService::asString → KILLED |
return value == null ? null : String.valueOf(value); |
| 168 | } | |
| 169 | } | |
Mutations | ||
| 66 |
1.1 |
|
| 71 |
1.1 |
|
| 78 |
1.1 2.2 |
|
| 81 |
1.1 |
|
| 119 |
1.1 |
|
| 124 |
1.1 |
|
| 131 |
1.1 |
|
| 136 |
1.1 |
|
| 151 |
1.1 |
|
| 157 |
1.1 2.2 |
|
| 161 |
1.1 2.2 |
|
| 167 |
1.1 2.2 |