atomic_lti/assignment_grade_services/
line_items.rs

1use crate::{errors::AssignmentGradeServicesError, request::send_request};
2use chrono::{DateTime, Utc};
3use reqwest::header;
4use serde::{Deserialize, Serialize};
5use serde_with::skip_serializing_none;
6use std::time::SystemTime;
7
8#[derive(Debug, Serialize, Deserialize)]
9pub struct LineItem {
10  pub id: String,
11  #[serde(rename = "scoreMaximum")]
12  pub score_maximum: f32,
13  pub label: String,
14  pub tag: String,
15  #[serde(rename = "resourceId")]
16  pub resource_id: String,
17  #[serde(rename = "resourceLinkId")]
18  pub resource_link_id: String,
19  #[serde(rename = "https://canvas.instructure.com/lti/submission_type")]
20  pub submission_type: Option<SubmissionType>,
21  #[serde(rename = "https://canvas.instructure.com/lti/launch_url")]
22  pub launch_url: Option<String>,
23}
24
25#[derive(Debug, Serialize, Deserialize)]
26pub struct NewLineItem {
27  #[serde(rename = "scoreMaximum")]
28  pub score_maximum: f32,
29  pub label: String,
30  #[serde(rename = "resourceId")]
31  pub resource_id: String,
32  pub tag: String,
33  #[serde(rename = "resourceLinkId")]
34  pub resource_link_id: String,
35  #[serde(rename = "endDateTime")]
36  pub end_date_time: String, // Must be ISO8601 date and time
37  #[serde(rename = "https://canvas.instructure.com/lti/submission_type")]
38  pub submission_type: Option<SubmissionType>,
39}
40
41impl NewLineItem {
42  pub fn new(
43    score_maximum: f32,
44    label: String,
45    resource_id: String,
46    tag: String,
47    resource_link_id: String,
48    submission_type: Option<SubmissionType>,
49  ) -> Self {
50    let now = SystemTime::now();
51    let datetime: DateTime<Utc> = now.into();
52    let iso_string = datetime.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
53    Self {
54      score_maximum,
55      label,
56      resource_id,
57      tag,
58      resource_link_id,
59      end_date_time: iso_string,
60      submission_type,
61    }
62  }
63}
64
65#[derive(Debug, Serialize, Deserialize)]
66pub struct UpdateLineItem {
67  #[serde(rename = "scoreMaximum")]
68  pub score_maximum: f32,
69  pub label: String,
70  #[serde(rename = "resourceId")]
71  pub resource_id: String,
72  pub tag: String,
73  #[serde(rename = "resourceLinkId")]
74  pub resource_link_id: String,
75  #[serde(rename = "endDateTime")]
76  pub end_date_time: String, // Must be ISO8601 date and time
77}
78
79impl UpdateLineItem {
80  pub fn new(
81    score_maximum: f32,
82    label: String,
83    resource_id: String,
84    tag: String,
85    resource_link_id: String,
86  ) -> Self {
87    let now = SystemTime::now();
88    let datetime: DateTime<Utc> = now.into();
89    let iso_string = datetime.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
90    Self {
91      score_maximum,
92      label,
93      resource_id,
94      tag,
95      resource_link_id,
96      end_date_time: iso_string,
97    }
98  }
99}
100
101#[derive(Debug, Serialize, Deserialize)]
102pub struct SubmissionType {
103  pub type_: String,
104  #[serde(alias = "externalToolUrl")]
105  pub external_tool_url: String,
106}
107
108// Gets a list of line items available based on the line_items_url
109// Parameters:
110// line_items_url: available from id_token.ags.lineitems
111// params: ListParams list of optional parameters to filter the list of line items
112#[derive(Debug, Serialize)]
113#[skip_serializing_none]
114pub struct ListParams {
115  pub tag: Option<String>,
116  pub resource_id: Option<String>,
117  pub resource_link_id: Option<String>,
118  pub limit: Option<usize>,
119  pub include: Option<Vec<String>>,
120}
121
122pub async fn list(
123  api_token: &str,
124  line_items_url: &str,
125  params: &ListParams,
126) -> Result<Vec<LineItem>, AssignmentGradeServicesError> {
127  let client = reqwest::Client::new();
128  let request = client
129    .get(line_items_url)
130    .header(header::AUTHORIZATION, format!("Bearer {}", api_token))
131    .header(
132      header::ACCEPT,
133      "application/vnd.ims.lis.v2.lineitemcontainer+json",
134    )
135    .query(&params);
136  let body = send_request(request).await?;
137
138  let response: Vec<LineItem> = serde_json::from_str(&body)
139    .map_err(|e| AssignmentGradeServicesError::RequestFailed(e.to_string()))?;
140
141  Ok(response)
142}
143
144pub async fn show(
145  api_token: &str,
146  line_item_url: &str,
147) -> Result<LineItem, AssignmentGradeServicesError> {
148  let client = reqwest::Client::new();
149  let request = client
150    .get(line_item_url)
151    .header(header::AUTHORIZATION, format!("Bearer {}", api_token))
152    .header(header::ACCEPT, "application/vnd.ims.lis.v2.lineitem+json");
153  let body = send_request(request).await?;
154
155  let response: LineItem = serde_json::from_str(&body)
156    .map_err(|e| AssignmentGradeServicesError::RequestFailed(e.to_string()))?;
157
158  Ok(response)
159}
160
161// Create a line item
162// Parameters:
163//  line_items_url: available from id_token.ags.lineitems
164//  params: ListParams list of optional parameters to filter the list of line items
165pub async fn create(
166  api_token: &str,
167  line_items_url: &str,
168  new_line_item: &NewLineItem,
169) -> Result<LineItem, AssignmentGradeServicesError> {
170  let client = reqwest::Client::new();
171  let json = serde_json::to_string(new_line_item)
172    .map_err(|e| AssignmentGradeServicesError::RequestFailed(e.to_string()))?;
173  let request = client
174    .post(line_items_url)
175    .header(header::AUTHORIZATION, format!("Bearer {}", api_token))
176    .header(header::ACCEPT, "application/vnd.ims.lis.v2.lineitem+json")
177    .body(json);
178  let body = send_request(request).await?;
179
180  let response: LineItem = serde_json::from_str(&body)
181    .map_err(|e| AssignmentGradeServicesError::RequestFailed(e.to_string()))?;
182
183  Ok(response)
184}
185
186pub async fn update(
187  api_token: &str,
188  line_item_url: &str,
189  update_line_item: &UpdateLineItem,
190) -> Result<LineItem, AssignmentGradeServicesError> {
191  let client = reqwest::Client::new();
192  let json = serde_json::to_string(update_line_item)
193    .map_err(|e| AssignmentGradeServicesError::RequestFailed(e.to_string()))?;
194  let request = client
195    .put(line_item_url)
196    .header(header::AUTHORIZATION, format!("Bearer {}", api_token))
197    .header(header::ACCEPT, "application/vnd.ims.lis.v2.lineitem+json")
198    .body(json);
199  let body = send_request(request).await?;
200
201  let response: LineItem = serde_json::from_str(&body)
202    .map_err(|e| AssignmentGradeServicesError::RequestFailed(e.to_string()))?;
203
204  Ok(response)
205}
206
207pub async fn delete(
208  api_token: &str,
209  line_item_url: &str,
210) -> Result<LineItem, AssignmentGradeServicesError> {
211  let client = reqwest::Client::new();
212
213  let request = client
214    .delete(line_item_url)
215    .header(header::AUTHORIZATION, format!("Bearer {}", api_token));
216  let body = send_request(request).await?;
217
218  let response: LineItem = serde_json::from_str(&body)
219    .map_err(|e| AssignmentGradeServicesError::RequestFailed(e.to_string()))?;
220
221  Ok(response)
222}
223
224#[cfg(test)]
225mod tests {
226  use super::*;
227  use mockito;
228
229  #[tokio::test]
230  async fn test_list() {
231    let mut server = mockito::Server::new_async().await;
232    let server_url = server.url();
233    let mock = server
234      .mock("GET", "/line_items")
235      .with_status(200)
236      .with_header(
237        "content-type",
238        "application/vnd.ims.lis.v2.lineitemcontainer+json",
239      )
240      .with_body(
241        r#"[
242            {
243              "id": "line_item_1",
244              "scoreMaximum": 100.0,
245              "label": "Test Item",
246              "tag": "test",
247              "resourceId": "res1",
248              "resourceLinkId": "link1",
249              "submission_type": null,
250              "launch_url": null
251            }
252        ]"#,
253      )
254      .create();
255
256    let api_token = "not a real token";
257    let params = ListParams {
258      tag: None,
259      resource_id: None,
260      resource_link_id: None,
261      limit: None,
262      include: None,
263    };
264    let url = format!("{}/line_items", &server_url);
265    let result = list(api_token, &url, &params).await;
266
267    mock.assert();
268    assert!(result.is_ok());
269    let response = result.unwrap();
270    assert_eq!(response.len(), 1);
271    assert_eq!(response[0].id, "line_item_1");
272  }
273
274  #[tokio::test]
275  async fn test_show() {
276    let mut server = mockito::Server::new_async().await;
277    let server_url = server.url();
278    let mock = server
279      .mock("GET", "/line_item_1")
280      .with_status(200)
281      .with_header("content-type", "application/vnd.ims.lis.v2.lineitem+json")
282      .with_body(
283        r#"{
284          "id": "line_item_1",
285          "scoreMaximum": 100.0,
286          "label": "Test Item",
287          "tag": "test",
288          "resourceId": "res1",
289          "resourceLinkId": "link1",
290          "submission_type": null,
291          "launch_url": null
292        }"#,
293      )
294      .create();
295
296    let api_token = "not a real token";
297    let result = show(api_token, &format!("{}/line_item_1", server_url)).await;
298
299    mock.assert();
300    assert!(result.is_ok());
301    let response = result.unwrap();
302    assert_eq!(response.id, "line_item_1");
303  }
304
305  #[tokio::test]
306  async fn test_create() {
307    let mut server = mockito::Server::new_async().await;
308    let server_url = server.url();
309    let mock = server
310      .mock("POST", "/line_items")
311      .with_status(201)
312      .with_header("content-type", "application/vnd.ims.lis.v2.lineitem+json")
313      .with_body(
314        r#"{
315          "id": "new_line_item",
316          "scoreMaximum": 100.0,
317          "label": "New Item",
318          "tag": "new",
319          "resourceId": "res_new",
320          "resourceLinkId": "link_new",
321          "submission_type": null,
322          "launch_url": null
323        }"#,
324      )
325      .create();
326
327    let api_token = "not a real token";
328    let new_line_item = NewLineItem::new(
329      100.0,
330      "New Item".to_string(),
331      "res_new".to_string(),
332      "new".to_string(),
333      "link_new".to_string(),
334      None,
335    );
336    let url = format!("{}/line_items", &server_url);
337    let result = create(api_token, &url, &new_line_item).await;
338
339    mock.assert();
340    assert!(result.is_ok());
341    let response = result.unwrap();
342    assert_eq!(response.id, "new_line_item");
343  }
344
345  #[tokio::test]
346  async fn test_update() {
347    let mut server = mockito::Server::new_async().await;
348    let server_url = server.url();
349    let mock = server
350      .mock("PUT", "/line_item_1")
351      .with_status(200)
352      .with_header("content-type", "application/vnd.ims.lis.v2.lineitem+json")
353      .with_body(
354        r#"{
355            "id": "line_item_1",
356            "scoreMaximum": 95.0,
357            "label": "Updated Item",
358            "tag": "updated",
359            "resourceId": "res1",
360            "resourceLinkId": "link1",
361            "submission_type": null,
362            "launch_url": null
363          }"#,
364      )
365      .create();
366
367    let api_token = "not a real token";
368    let update_line_item = UpdateLineItem::new(
369      95.0,
370      "Updated Item".to_string(),
371      "res1".to_string(),
372      "updated".to_string(),
373      "link1".to_string(),
374    );
375    let result = update(
376      api_token,
377      &format!("{}/line_item_1", server_url),
378      &update_line_item,
379    )
380    .await;
381
382    mock.assert();
383    assert!(result.is_ok());
384    let response = result.unwrap();
385    assert_eq!(response.label, "Updated Item");
386  }
387
388  #[tokio::test]
389  async fn test_delete() {
390    let mut server = mockito::Server::new_async().await;
391    let server_url = server.url();
392    let mock = server
393      .mock("DELETE", "/line_item_1")
394      .with_status(200)
395      .with_header("content-type", "application/vnd.ims.lis.v2.lineitem+json")
396      .with_body(
397        r#"{
398          "id": "line_item_1",
399          "scoreMaximum": 100.0,
400          "label": "Deleted Item",
401          "tag": "deleted",
402          "resourceId": "res1",
403          "resourceLinkId": "link1",
404          "submission_type": null,
405          "launch_url": null
406        }"#,
407      )
408      .create();
409
410    let api_token = "not a real token";
411    let result = delete(api_token, &format!("{}/line_item_1", server_url)).await;
412
413    mock.assert();
414    assert!(result.is_ok());
415    let response = result.unwrap();
416    assert_eq!(response.label, "Deleted Item");
417  }
418}