Persisting Service Endpoints

Copy Markdown View Source

Service calls that happen outside an LTI launch need endpoint URLs that were stored during one. Consider a mobile attendance app. The LTI launch sets up the course, but grade posting happens later when students check in on their phones. This cookbook shows what to persist and where, using that app as a running example.

Programmatic line items

The tool creates its own "Attendance" line item in the gradebook and posts scores to it. It also syncs the roster so it can pair students' mobile accounts with their LMS identities using the LIS data from membership responses.

Both URLs are context-scoped, so store them on the course. The grade service also needs the scopes from the launch claim, and the line item URL the platform returns when the tool creates its line item.

Migration

defmodule MyApp.Repo.Migrations.AddServiceEndpointsToCourses do
  use Ecto.Migration

  def change do
    alter table(:courses) do
      add :memberships_url, :string
      add :lineitems_url, :string
      add :ags_scopes, {:array, :string}
      add :attendance_line_item_url, :string
    end
  end
end

Schema

# lib/my_app/course.ex
defmodule MyApp.Course do
  use Ecto.Schema
  import Ecto.Changeset

  schema "courses" do
    field :context_id, :string
    field :title, :string
    field :memberships_url, :string
    field :lineitems_url, :string
    field :ags_scopes, {:array, :string}
    field :attendance_line_item_url, :string
    belongs_to :registration, MyApp.Lti.Registration
    timestamps()
  end

  def changeset(course, attrs) do
    course
    |> cast(attrs, [
      :context_id,
      :title,
      :registration_id,
      :memberships_url,
      :lineitems_url,
      :ags_scopes,
      :attendance_line_item_url
    ])
    |> validate_required([:context_id, :registration_id])
    |> unique_constraint([:context_id, :registration_id])
  end
end

Storing during launch

Extract the endpoint URLs in create_course!/1. Either claim may be nil if the platform doesn't support that service. The upsert replaces endpoint URLs on every launch, keeping them current if the platform ever changes them.

def create_course!(%Ltix.LaunchContext{claims: claims, registration: registration}) do
  attrs = %{
    context_id: claims.context.id,
    registration_id: registration.id,
    title: claims.context.title,
    memberships_url: claims.memberships_endpoint && claims.memberships_endpoint.context_memberships_url,
    lineitems_url: claims.ags_endpoint && claims.ags_endpoint.line_items,
    ags_scopes: claims.ags_endpoint && claims.ags_endpoint.scopes
  }

  %Course{}
  |> Course.changeset(attrs)
  |> Repo.insert!(
    on_conflict: {:replace, [:title, :memberships_url, :lineitems_url, :ags_scopes, :updated_at]},
    conflict_target: [:context_id, :registration_id],
    returning: true
  )
end

Create the attendance line item once, during the first launch, and store its URL.

def ensure_attendance_line_item!(course, context) do
  if course.attendance_line_item_url do
    course
  else
    {:ok, client} = Ltix.GradeService.authenticate(context)

    {:ok, item} = Ltix.GradeService.create_line_item(client,
      label: "Attendance",
      score_maximum: 1,
      tag: "attendance"
    )

    course
    |> Course.changeset(%{attendance_line_item_url: item.id})
    |> Repo.update!()
  end
end

Using stored endpoints

Sync the roster periodically to pair mobile accounts with LMS users.

alias Ltix.LaunchClaims.MembershipsEndpoint

def sync_roster(course) do
  registration = get_registration(course)
  endpoint = MembershipsEndpoint.new!(course.memberships_url)

  {:ok, client} = Ltix.MembershipsService.authenticate(registration,
    endpoint: endpoint
  )

  {:ok, members} = Ltix.MembershipsService.list_members(client)
  # Pair mobile accounts with LMS users using LIS data from members
end

Post a score to the line item the tool created earlier.

alias Ltix.LaunchClaims.AGSEndpoint

def post_attendance(course, user_id) do
  registration = get_registration(course)

  endpoint =
    AGSEndpoint.new!(line_items: course.lineitems_url, scopes: course.ags_scopes)

  {:ok, client} = Ltix.GradeService.authenticate(registration,
    endpoint: endpoint
  )

  {:ok, score} = Ltix.GradeService.Score.new(
    user_id: user_id,
    score_given: 1,
    score_maximum: 1,
    activity_progress: :completed,
    grading_progress: :fully_graded
  )

  :ok = Ltix.GradeService.post_score(client, score,
    line_item: course.attendance_line_item_url
  )
end

Coupled line items

With coupled line items, the instructor creates one tool placement per class session, such as "Week 1" and "Week 2". The platform pre-creates a grade column for each placement. The lineitem URL for that column and its scopes are resource-link-scoped (different per session), so store them in a separate schema.

Migration

defmodule MyApp.Repo.Migrations.CreateClassSessions do
  use Ecto.Migration

  def change do
    create table(:class_sessions) do
      add :resource_link_id, :string, null: false
      add :title, :string
      add :lineitem_url, :string
      add :ags_scopes, {:array, :string}
      add :course_id, references(:courses), null: false
      timestamps()
    end

    create unique_index(:class_sessions, [:resource_link_id, :course_id])
  end
end

Schema

# lib/my_app/class_session.ex
defmodule MyApp.ClassSession do
  use Ecto.Schema
  import Ecto.Changeset

  schema "class_sessions" do
    field :resource_link_id, :string
    field :title, :string
    field :lineitem_url, :string
    field :ags_scopes, {:array, :string}
    belongs_to :course, MyApp.Course
    timestamps()
  end

  def changeset(session, attrs) do
    session
    |> cast(attrs, [:resource_link_id, :title, :lineitem_url, :ags_scopes, :course_id])
    |> validate_required([:resource_link_id, :course_id])
    |> unique_constraint([:resource_link_id, :course_id])
  end
end

Storing during launch

def create_class_session!(%Ltix.LaunchContext{claims: claims}, course) do
  attrs = %{
    resource_link_id: claims.resource_link.id,
    title: claims.resource_link.title,
    lineitem_url: claims.ags_endpoint && claims.ags_endpoint.line_item,
    ags_scopes: claims.ags_endpoint && claims.ags_endpoint.scopes,
    course_id: course.id
  }

  %ClassSession{}
  |> ClassSession.changeset(attrs)
  |> Repo.insert!(
    on_conflict: {:replace, [:title, :lineitem_url, :ags_scopes, :updated_at]},
    conflict_target: [:resource_link_id, :course_id],
    returning: true
  )
end

Using stored endpoints

When a student checks in for a specific session, post the score directly to that session's grade column.

alias Ltix.LaunchClaims.AGSEndpoint

def post_check_in(session, user_id) do
  course = Repo.preload(session, :course).course
  registration = get_registration(course)
  endpoint = AGSEndpoint.new!(line_item: session.lineitem_url, scopes: session.ags_scopes)

  {:ok, client} = Ltix.GradeService.authenticate(registration,
    endpoint: endpoint
  )

  {:ok, score} = Ltix.GradeService.Score.new(
    user_id: user_id,
    score_given: 1,
    score_maximum: 1,
    activity_progress: :completed,
    grading_progress: :fully_graded
  )

  :ok = Ltix.GradeService.post_score(client, score)
end

See Advantage Services for an overview of token management, Syncing Grades in the Background for Oban worker patterns, and Token Caching and Reuse for batch refreshing across courses.