PrairieLearnService.java

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
Location : getCourseInstance
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:builds_the_url_and_private_token_header_from_its_arguments()]
removed call to org/springframework/http/HttpHeaders::set → KILLED

71

1.1
Location : lambda$getCourseInstance$0
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:returns_null_when_the_body_is_null()]
replaced return value with null for edu/ucsb/cs/scaffold/services/PrairieLearnService::lambda$getCourseInstance$0 → KILLED

78

1.1
Location : getCourseInstance
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:returns_null_when_the_body_has_no_course_instance_id()]
negated conditional → KILLED

2.2
Location : getCourseInstance
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:returns_null_when_the_body_is_null()]
negated conditional → KILLED

81

1.1
Location : getCourseInstance
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:parses_a_numeric_course_instance_id_as_well_as_a_string_one()]
replaced return value with null for edu/ucsb/cs/scaffold/services/PrairieLearnService::getCourseInstance → KILLED

119

1.1
Location : getAssessments
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:assessments_url_and_private_token_header_are_built_from_the_arguments()]
removed call to org/springframework/http/HttpHeaders::set → KILLED

124

1.1
Location : lambda$getAssessments$1
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:returns_an_empty_list_when_the_body_is_null()]
replaced return value with null for edu/ucsb/cs/scaffold/services/PrairieLearnService::lambda$getAssessments$1 → KILLED

131

1.1
Location : getAssessments
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:returns_an_empty_list_when_the_body_is_null()]
negated conditional → KILLED

136

1.1
Location : getAssessments
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:entries_without_an_assessment_id_are_skipped()]
negated conditional → KILLED

151

1.1
Location : getAssessments
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:entries_without_an_assessment_id_are_skipped()]
replaced return value with Collections.emptyList for edu/ucsb/cs/scaffold/services/PrairieLearnService::getAssessments → KILLED

157

1.1
Location : asLong
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:entries_without_an_assessment_id_are_skipped()]
negated conditional → KILLED

2.2
Location : asLong
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:missing_optional_fields_come_back_as_null()]
replaced Long return value with 0L for edu/ucsb/cs/scaffold/services/PrairieLearnService::asLong → KILLED

161

1.1
Location : asInteger
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:missing_optional_fields_come_back_as_null()]
replaced Integer return value with 0 for edu/ucsb/cs/scaffold/services/PrairieLearnService::asInteger → KILLED

2.2
Location : asInteger
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:entries_without_an_assessment_id_are_skipped()]
negated conditional → KILLED

167

1.1
Location : asString
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:missing_optional_fields_come_back_as_null()]
negated conditional → KILLED

2.2
Location : asString
Killed by : edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PrairieLearnServiceTests]/[method:missing_optional_fields_come_back_as_null()]
replaced return value with "" for edu/ucsb/cs/scaffold/services/PrairieLearnService::asString → KILLED

Active mutators

Tests examined


Report generated by PIT 1.17.0