lib/lti_1p3/tool/services/ags.ex

defmodule Lti_1p3.Tool.Services.AGS do
  @moduledoc """
  Implementation of LTI Assignment and Grading Services (AGS) version 2.0.

  For information on the standard, see:
  https://www.imsglobal.org/spec/lti-ags/v2p0/
  """

  @lti_ags_claim_url "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint"
  @lineitem_scope_url "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"
  @scores_scope_url "https://purl.imsglobal.org/spec/lti-ags/scope/score"

  alias Lti_1p3.Tool.Services.AGS.Score
  alias Lti_1p3.Tool.Services.AGS.LineItem
  alias Lti_1p3.Tool.Services.AccessToken

  import Lti_1p3.Config

  require Logger

  @doc """
  Post a score to an existing line item, using an already acquired access token.
  """
  def post_score(%Score{} = score, %LineItem{} = line_item, %AccessToken{} = access_token) do
    Logger.info("Posting score for user #{score.userId} for line item '#{line_item.label}'")

    body = score |> Jason.encode!()

    case http_client!().post(build_url_with_path(line_item.id, "scores"), body, score_headers(access_token)) do
      {:ok, %HTTPoison.Response{status_code: code, body: body}} when code in [200, 201] ->
        {:ok, body}
      e ->
        Logger.error(
          "Error encountered posting score for user #{score.userId} for line item '#{line_item.label}' #{inspect(e)}"
        )

        {:error, "Error posting score"}
    end
  end

  @doc """
  Creates a line item for a resource id, if one does not exist.  Whether or not the
  line item is created or already exists, this function returns a line item struct wrapped
  in a {:ok, line_item} tuple.  On error, returns a {:error, error} tuple.
  """
  def fetch_or_create_line_item(
        line_items_service_url,
        resource_id,
        maximum_score_provider,
        label,
        %AccessToken{} = access_token
      ) do
    Logger.info("fetch_or_create_line_item #{resource_id} #{label}")

    # Grade pass back 2.0 line items endpoint allows a GET request with a query
    # param filter.  We use that to request only the line item that corresponds
    # to this particular resource_id.  "resource_id", from grade pass back 2.0
    # perspective is simply an identifier that the tool uses for a line item and its use
    # here as a Torus "resource_id" is strictly coincidence.

    prefixed_resource_id = LineItem.to_resource_id(resource_id)
    request_url = build_url_with_params(line_items_service_url, "resource_id=#{prefixed_resource_id}&limit=1")

    Logger.info("fetch_or_create_line_item: URL #{request_url}")

    with {:ok, %HTTPoison.Response{status_code: code, body: body}} when code in [200, 201] <-
           http_client!().get(request_url, headers(access_token)),
         {:ok, result} <- Jason.decode(body) do
      case result do
        [] ->
          Logger.info("fetch_or_create_line_item #{resource_id} #{label}")

          create_line_item(
            line_items_service_url,
            resource_id,
            maximum_score_provider.(),
            label,
            access_token
          )

        # it is important to match against a possible array of items, in case an LMS does
        # not properly support the limit parameter
        [raw_line_item | _] ->

          Logger.info("fetch_or_create_line_item: Retrieved raw line item #{inspect(raw_line_item)} for #{resource_id} #{label}")

          line_item = to_line_item(raw_line_item)

          if line_item.label != label do
            update_line_item(line_item, %{label: label}, access_token)
          else
            {:ok, line_item}
          end
      end
    else
      e ->
        Logger.error(
          "Error encountered fetching line item for #{resource_id} #{label}: #{inspect(e)}"
        )

        {:error, "Error retrieving existing line items"}
    end
  end

  defp to_line_item(raw_line_item) do
    %LineItem{
      id: Map.get(raw_line_item, "id"),
      scoreMaximum: Map.get(raw_line_item, "scoreMaximum"),
      resourceId: Map.get(raw_line_item, "resourceId"),
      label: Map.get(raw_line_item, "label")
    }
  end

  def fetch_line_items(line_items_service_url, %AccessToken{} = access_token) do
    Logger.info("Fetch line items from #{line_items_service_url}")

    # Unfortunately, at least Canvas implements a default limit of 10 line items
    # when one makes a request without a 'limit' parameter specified. Setting it explicitly to 1000
    # bypasses this default limit, of course, and works in all cases until a course more than
    # a thousand grade book entries.
    url = build_url_with_params(line_items_service_url, "limit=1000")

    with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
           http_client!().get(url, headers(access_token)),
         {:ok, results} <- Jason.decode(body) do
      {:ok, Enum.map(results, fn r -> to_line_item(r) end)}
    else
      e ->
        Logger.error("Error encountered fetching line items from #{url} #{inspect(e)}")
        {:error, "Error retrieving all line items"}
    end
  end

  @doc """
  Creates a line item for a resource id. This function returns a line item struct wrapped
  in a {:ok, line_item} tuple.  On error, returns a {:error, error} tuple.
  """
  def create_line_item(
        line_items_service_url,
        resource_id,
        score_maximum,
        label,
        %AccessToken{} = access_token
      ) do
    Logger.info("Create line item for #{resource_id} #{label}")

    line_item = %LineItem{
      scoreMaximum: score_maximum,
      resourceId: LineItem.to_resource_id(resource_id),
      label: label
    }

    body = line_item |> Jason.encode!()

    with {:ok, %HTTPoison.Response{status_code: code, body: body}} when code in [200, 201] <-
           http_client!().post(line_items_service_url, body, headers(access_token)),
         {:ok, result} <- Jason.decode(body) do
      {:ok, to_line_item(result)}
    else
      e ->
        Logger.error(
          "Error encountered creating line item for #{resource_id} #{label}: #{inspect(e)}"
        )

        {:error, "Error creating new line item"}
    end
  end

  @doc """
  Updates an existing line item. On success returns
  a {:ok, line_item} tuple.  On error, returns a {:error, error} tuple.
  """
  def update_line_item(%LineItem{} = line_item, changes, %AccessToken{} = access_token) do
    Logger.info("Updating line item #{line_item.id} for changes #{inspect(changes)}")

    updated_line_item = %LineItem{
      id: line_item.id,
      scoreMaximum: Map.get(changes, :scoreMaximum, line_item.scoreMaximum),
      resourceId: line_item.resourceId,
      label: Map.get(changes, :label, line_item.label)
    }

    body = updated_line_item |> Jason.encode!()

    # The line_item endpoint defines a PUT operation to update existing line items.  The
    # url to use is the id of the line item
    url = line_item.id

    with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
           http_client!().put(url, body, headers(access_token)),
         {:ok, result} <- Jason.decode(body) do
      {:ok, to_line_item(result)}
    else
      e ->
        Logger.error(
          "Error encountered updating line item #{line_item.id} for changes #{inspect(changes)}: #{inspect(e)}"
        )

        {:error, "Error updating existing line item"}
    end
  end

  @doc """
  Returns true if grade pass back service is enabled with the necessary scopes. The
  necessary scopes are the line item scope to read all line items and create new ones
  and the scores scope, to be able to post new scores. Also verifies that the line items
  endpoint is present.
  """
  def grade_passback_enabled?(lti_launch_params) do
    case Map.get(lti_launch_params, @lti_ags_claim_url) do
      nil ->
        false

      config ->
        Map.has_key?(config, "lineitems") and has_scope?(config, @lineitem_scope_url) and
          has_scope?(config, @scores_scope_url)
    end
  end

  @doc """
  Returns the line items URL from LTI launch params.
  If not present returns nil.
  If a registration is present, uses the auth server domain + the line items path.
  """
  def get_line_items_url(lti_launch_params, registration \\ %{}) do
    line_items_url =
      lti_launch_params
      |> Map.get(@lti_ags_claim_url, %{})
      |> Map.get("lineitems")

    unless is_nil(line_items_url) do
      %URI{path: line_items_path} = URI.parse(line_items_url)

      registration
      |> get_line_items_domain(line_items_url)
      |> URI.parse()
      |> Map.put(:path, line_items_path)
      |> URI.to_string()
    end
  end

  defp get_line_items_domain(%{line_items_service_domain: domain}, default)
    when is_nil(domain) or domain == "", do: default
  defp get_line_items_domain(%{line_items_service_domain: domain}, _default), do: domain
  defp get_line_items_domain(_registration, default), do: default

  @doc """
  Returns true if the LTI AGS claim has a particular scope url, false if it does not.
  """
  def has_scope?(lti_ags_claim, scope_url) do
    case Map.get(lti_ags_claim, "scope", [])
         |> Enum.find(nil, fn url -> scope_url == url end) do
      nil -> false
      _ -> true
    end
  end

  @doc """
  Returns the required scopes for the AGS service.
  """
  def required_scopes() do
    [
      @lineitem_scope_url,
      @scores_scope_url
    ]
  end

  # ---------------------------------------------------------
  # Helpers to build headers correctly
  defp headers(%AccessToken{} = access_token) do
    [
      {"Accept", "application/vnd.ims.lis.v2.lineitemcontainer+json"},
      {"Content-Type", "application/vnd.ims.lis.v2.lineitem+json"}
    ] ++ access_token_header(access_token.access_token)
  end

  defp score_headers(%AccessToken{} = access_token) do
    [{"Content-Type", "application/vnd.ims.lis.v1.score+json"}]
      ++ access_token_header(access_token.access_token)
  end

  defp access_token_header(access_token),
    do: [{"Authorization", "Bearer #{access_token}"}]

  # ---------------------------------------------------------
  # Helpers to build urls correctly (if base url contian query params)
  defp build_url_with_path(base_url, path_to_add) do
    case String.split(base_url, "?") do
      [base_url, query_params] -> "#{base_url}/#{path_to_add}?#{query_params}"
      _ -> "#{base_url}/#{path_to_add}"
    end
  end

  defp build_url_with_params(base_url, params_to_add) do
    case String.split(base_url, "?") do
      [base_url, query_params] -> "#{base_url}?#{query_params}&#{params_to_add}"
      _ -> "#{base_url}?#{params_to_add}"
    end
  end
end