PrairieLearnService.java

package edu.ucsb.cs.scaffold.services;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

/**
 * Thin wrapper around the PrairieLearn REST API (see https://docs.prairielearn.com/api/),
 * authenticated per call with a user's PrairieLearn personal access token. Callers handle
 * PrairieLearn's HTTP errors (401 = bad token, 403/404 = unknown or inaccessible course instance),
 * which RestTemplate surfaces as HttpClientErrorException subclasses.
 */
@Service
public class PrairieLearnService {

  /**
   * The parts of a course instance response this app uses; the numeric id is the crucial one, since
   * it cannot be obtained from the GitHub repo.
   */
  public record CourseInstanceInfo(Long courseInstanceId, String longName, String shortName) {}

  private final RestTemplate restTemplate;
  private final String plApiBase;
  private final ApiRetryHelper retryHelper;

  public PrairieLearnService(
      RestTemplate restTemplate,
      @Value("${pl.api.base:https://us.prairielearn.com/pl/api/v1}") String plApiBase,
      @Value("${PL_SERVICE_RETRY_INITIAL_SLEEP_SECONDS:8}") long retryInitialSleepSeconds,
      @Value("${PL_SERVICE_RETRY_MAX:3}") int retryMax,
      @Value("${PL_SERVICE_RATE_LIMIT_SLEEP_INITIAL_MS:1000}") long rateLimitSleepInitialMs) {
    this.restTemplate = restTemplate;
    this.plApiBase = plApiBase;
    this.retryHelper =
        new ApiRetryHelper(
            "PrairieLearn",
            "PL_SERVICE_RATE_LIMIT_SLEEP_INITIAL_MS",
            retryInitialSleepSeconds,
            retryMax,
            rateLimitSleepInitialMs);
  }

  /**
   * Fetches one course instance by its numeric id, e.g. {@code /course_instances/213133}. Returns
   * null when the response has no usable body.
   *
   * @param instanceId PrairieLearn's numeric course instance id
   * @param token the user's PrairieLearn PAT (plaintext)
   */
  public CourseInstanceInfo getCourseInstance(long instanceId, String token) {
    String url =
        UriComponentsBuilder.fromUriString(plApiBase)
            .pathSegment("course_instances", String.valueOf(instanceId))
            .toUriString();

    HttpHeaders headers = new HttpHeaders();
    headers.set("Private-Token", token);
    ResponseEntity<Map<String, Object>> response =
        retryHelper.execute(
            "GET " + url,
            () ->
                restTemplate.exchange(
                    url,
                    HttpMethod.GET,
                    new HttpEntity<>(headers),
                    new ParameterizedTypeReference<>() {}));

    Map<String, Object> body = response.getBody();
    if (body == null || body.get("course_instance_id") == null) {
      return null;
    }
    return new CourseInstanceInfo(
        // PrairieLearn returns ids as JSON strings, e.g. "213133".
        Long.valueOf(String.valueOf(body.get("course_instance_id"))),
        (String) body.get("course_instance_long_name"),
        (String) body.get("course_instance_short_name"));
  }

  /**
   * One assessment from the {@code /course_instances/{id}/assessments} response — the fields
   * PlAssessment stores (issue #71). {@code assessmentName} matches the assessment's directory name
   * in the GitHub repo (PlAssessment.name), which is how the two sources are joined.
   */
  public record AssessmentInfo(
      Long assessmentId,
      String assessmentName,
      // alphanumeric, e.g. "2" or "1a"
      String assessmentNumber,
      Long assessmentOrderBy,
      String title,
      String assessmentSetAbbreviation,
      Integer assessmentSetNumber,
      String assessmentSetHeading,
      String assessmentSetColor) {}

  /**
   * Fetches all assessments of a course instance, e.g. {@code
   * /course_instances/213133/assessments}. Entries without an {@code assessment_id} are skipped.
   *
   * @param instanceId PrairieLearn's numeric course instance id
   * @param token the user's PrairieLearn PAT (plaintext)
   */
  public List<AssessmentInfo> getAssessments(long instanceId, String token) {
    String url =
        UriComponentsBuilder.fromUriString(plApiBase)
            .pathSegment("course_instances", String.valueOf(instanceId), "assessments")
            .toUriString();

    HttpHeaders headers = new HttpHeaders();
    headers.set("Private-Token", token);
    ResponseEntity<List<Map<String, Object>>> response =
        retryHelper.execute(
            "GET " + url,
            () ->
                restTemplate.exchange(
                    url,
                    HttpMethod.GET,
                    new HttpEntity<>(headers),
                    new ParameterizedTypeReference<>() {}));

    List<Map<String, Object>> body = response.getBody();
    if (body == null) {
      return List.of();
    }
    List<AssessmentInfo> assessments = new ArrayList<>();
    for (Map<String, Object> item : body) {
      if (item.get("assessment_id") == null) {
        continue;
      }
      assessments.add(
          new AssessmentInfo(
              asLong(item.get("assessment_id")),
              (String) item.get("assessment_name"),
              asString(item.get("assessment_number")),
              asLong(item.get("assessment_order_by")),
              (String) item.get("title"),
              (String) item.get("assessment_set_abbreviation"),
              asInteger(item.get("assessment_set_number")),
              (String) item.get("assessment_set_heading"),
              (String) item.get("assessment_set_color")));
    }
    return assessments;
  }

  // PrairieLearn is inconsistent about numbers: some come as JSON strings ("2690012"), some as
  // JSON numbers (6). Both parse via their string form; null stays null.
  private static Long asLong(Object value) {
    return value == null ? null : Long.valueOf(String.valueOf(value));
  }

  private static Integer asInteger(Object value) {
    return value == null ? null : Integer.valueOf(String.valueOf(value));
  }

  // assessment_number is alphanumeric ("1a") but other fields may arrive as JSON numbers,
  // so normalize through String.valueOf rather than casting.
  private static String asString(Object value) {
    return value == null ? null : String.valueOf(value);
  }
}