woooooooo

This commit is contained in:
Tyler Beckman 2024-10-16 01:40:11 -06:00
parent 7cea0255b4
commit b0e785a2fc
Signed by: Ty
GPG key ID: 2813440C772555A4
13 changed files with 1554 additions and 25 deletions

1296
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,13 @@
[package] [package]
name = "canvas-autouploader" name = "canvas-rs"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0.89"
bpaf = { version = "0.9.15", features = ["derive"] }
graphql_client = "0.14.0" graphql_client = "0.14.0"
keyring = { version = "3.4.0", features = ["apple-native", "windows-native", "sync-secret-service"] }
reqwest = { version = "0.12.8", default-features = false, features = ["json", "rustls-tls-native-roots", "http2"] }
serde = "1.0.210" serde = "1.0.210"
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }

2
graphql.config.yml Normal file
View file

@ -0,0 +1,2 @@
schema: ./graphql/canvas-schema.json
documents: ./graphql/queries/*.graphql

View file

@ -0,0 +1,12 @@
query GetCourses {
allCourses {
name
courseCode
courseNickname
term {
name
startAt
endAt
}
}
}

35
src/command.rs Normal file
View file

@ -0,0 +1,35 @@
use bpaf::Bpaf;
#[derive(Clone, Debug, Bpaf)]
#[bpaf(options, version)]
/// A command to interface with the Canvas API, primarily upload code to supported class assignments.
pub enum CanvasCommand {
#[bpaf(command("courses"), long("course"), long("classes"), long("class"), short('c'))]
Courses {
#[bpaf(external(canvas_courses_subcommand))]
command: CanvasCoursesSubcommand
},
#[bpaf(command("user"), long("account"), short('u'))]
User {
#[bpaf(external(canvas_user_subcommand))]
command: CanvasUserSubcommand
}
}
#[derive(Clone, Debug, Bpaf)]
pub enum CanvasCoursesSubcommand {
/// Lists the classes for the logged in account
#[bpaf(command("list"), short('l'))]
List
}
#[derive(Clone, Debug, Bpaf)]
pub enum CanvasUserSubcommand {
/// Logs into canvas with a token
#[bpaf(command("login"), short('l'))]
Login {
#[bpaf(positional("TOKEN"))]
/// The canvas token to login with
token: String
}
}

View file

@ -1,4 +1,5 @@
#![allow(clippy::all, warnings)] #![allow(clippy::all, warnings)]
#[derive(Debug)]
pub struct CreateSubmission; pub struct CreateSubmission;
pub mod create_submission { pub mod create_submission {
#![allow(dead_code)] #![allow(dead_code)]
@ -15,38 +16,38 @@ pub mod create_submission {
type Int = i64; type Int = i64;
#[allow(dead_code)] #[allow(dead_code)]
type ID = String; type ID = String;
#[derive(Serialize)] #[derive(Serialize, Debug)]
pub struct Variables { pub struct Variables {
pub file_id: ID, pub file_id: ID,
pub assignment_id: ID, pub assignment_id: ID,
} }
impl Variables {} impl Variables {}
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct ResponseData { pub struct ResponseData {
#[serde(rename = "createSubmission")] #[serde(rename = "createSubmission")]
pub create_submission: Option<CreateSubmissionCreateSubmission>, pub create_submission: Option<CreateSubmissionCreateSubmission>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct CreateSubmissionCreateSubmission { pub struct CreateSubmissionCreateSubmission {
pub errors: Option<Vec<CreateSubmissionCreateSubmissionErrors>>, pub errors: Option<Vec<CreateSubmissionCreateSubmissionErrors>>,
pub submission: Option<CreateSubmissionCreateSubmissionSubmission>, pub submission: Option<CreateSubmissionCreateSubmissionSubmission>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct CreateSubmissionCreateSubmissionErrors { pub struct CreateSubmissionCreateSubmissionErrors {
pub message: String, pub message: String,
pub attribute: Option<String>, pub attribute: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct CreateSubmissionCreateSubmissionSubmission { pub struct CreateSubmissionCreateSubmissionSubmission {
pub id: ID, pub id: ID,
pub assignment: Option<CreateSubmissionCreateSubmissionSubmissionAssignment>, pub assignment: Option<CreateSubmissionCreateSubmissionSubmissionAssignment>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct CreateSubmissionCreateSubmissionSubmissionAssignment { pub struct CreateSubmissionCreateSubmissionSubmissionAssignment {
pub name: Option<String>, pub name: Option<String>,
pub course: Option<CreateSubmissionCreateSubmissionSubmissionAssignmentCourse>, pub course: Option<CreateSubmissionCreateSubmissionSubmissionAssignmentCourse>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct CreateSubmissionCreateSubmissionSubmissionAssignmentCourse { pub struct CreateSubmissionCreateSubmissionSubmissionAssignmentCourse {
pub name: String, pub name: String,
#[serde(rename = "courseCode")] #[serde(rename = "courseCode")]

View file

@ -1,4 +1,5 @@
#![allow(clippy::all, warnings)] #![allow(clippy::all, warnings)]
#[derive(Debug)]
pub struct GetCourseAssignments; pub struct GetCourseAssignments;
pub mod get_course_assignments { pub mod get_course_assignments {
#![allow(dead_code)] #![allow(dead_code)]
@ -15,7 +16,7 @@ pub mod get_course_assignments {
type Int = i64; type Int = i64;
#[allow(dead_code)] #[allow(dead_code)]
type ID = String; type ID = String;
#[derive()] #[derive(Debug)]
pub enum SubmissionType { pub enum SubmissionType {
attendance, attendance,
basic_lti_launch, basic_lti_launch,
@ -76,35 +77,34 @@ pub mod get_course_assignments {
} }
} }
} }
#[derive(Serialize)] #[derive(Serialize, Debug)]
pub struct Variables; pub struct Variables;
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct ResponseData { pub struct ResponseData {
#[serde(rename = "allCourses")] #[serde(rename = "allCourses")]
pub all_courses: Option<Vec<GetCourseAssignmentsAllCourses>>, pub all_courses: Option<Vec<GetCourseAssignmentsAllCourses>>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct GetCourseAssignmentsAllCourses { pub struct GetCourseAssignmentsAllCourses {
#[serde(rename = "_id")] pub _id: ID,
pub id: ID,
#[serde(rename = "courseCode")] #[serde(rename = "courseCode")]
pub course_code: Option<String>, pub course_code: Option<String>,
#[serde(rename = "assignmentGroups")] #[serde(rename = "assignmentGroups")]
pub assignment_groups: Option<Vec<GetCourseAssignmentsAllCoursesAssignmentGroups>>, pub assignment_groups: Option<Vec<GetCourseAssignmentsAllCoursesAssignmentGroups>>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct GetCourseAssignmentsAllCoursesAssignmentGroups { pub struct GetCourseAssignmentsAllCoursesAssignmentGroups {
#[serde(rename = "assignmentsConnection")] #[serde(rename = "assignmentsConnection")]
pub assignments_connection: pub assignments_connection:
Option<GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnection>, Option<GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnection>,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnection { pub struct GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnection {
pub nodes: Option< pub nodes: Option<
Vec<Option<GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnectionNodes>>, Vec<Option<GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnectionNodes>>,
>, >,
} }
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnectionNodes { pub struct GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnectionNodes {
pub _id: ID, pub _id: ID,
pub id: ID, pub id: ID,

View file

@ -0,0 +1,58 @@
#![allow(clippy::all, warnings)]
type DateTime = String;
#[derive(Debug)]
pub struct GetCourses;
pub mod get_courses {
#![allow(dead_code)]
use std::result::Result;
pub const OPERATION_NAME: &str = "GetCourses";
pub const QUERY : & str = "query GetCourses {\n allCourses {\n name\n courseCode\n courseNickname\n term {\n name\n startAt\n endAt\n }\n }\n}" ;
use super::*;
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
type Boolean = bool;
#[allow(dead_code)]
type Float = f64;
#[allow(dead_code)]
type Int = i64;
#[allow(dead_code)]
type ID = String;
type DateTime = super::DateTime;
#[derive(Serialize, Debug)]
pub struct Variables;
#[derive(Deserialize, Debug)]
pub struct ResponseData {
#[serde(rename = "allCourses")]
pub all_courses: Option<Vec<GetCoursesAllCourses>>,
}
#[derive(Deserialize, Debug)]
pub struct GetCoursesAllCourses {
pub name: String,
#[serde(rename = "courseCode")]
pub course_code: Option<String>,
#[serde(rename = "courseNickname")]
pub course_nickname: Option<String>,
pub term: Option<GetCoursesAllCoursesTerm>,
}
#[derive(Deserialize, Debug)]
pub struct GetCoursesAllCoursesTerm {
pub name: Option<String>,
#[serde(rename = "startAt")]
pub start_at: Option<DateTime>,
#[serde(rename = "endAt")]
pub end_at: Option<DateTime>,
}
}
impl graphql_client::GraphQLQuery for GetCourses {
type Variables = get_courses::Variables;
type ResponseData = get_courses::ResponseData;
fn build_query(variables: Self::Variables) -> ::graphql_client::QueryBody<Self::Variables> {
graphql_client::QueryBody {
variables,
query: get_courses::QUERY,
operation_name: get_courses::OPERATION_NAME,
}
}
}

44
src/graphql/mod.rs Normal file
View file

@ -0,0 +1,44 @@
use anyhow::Context as _;
use graphql_client::GraphQLQuery;
use serde::Deserialize;
pub mod create_submission;
pub mod get_course_assignments;
pub mod get_courses;
const CANVAS_GRAPHQL_URL: &str = "https://elearning.mines.edu/api/graphql";
#[derive(Deserialize, Debug)]
pub struct GraphQLResponse<T: GraphQLQuery> {
pub data: T::ResponseData
}
pub trait GraphQLClient {
/// Sends a GraphQL query using this HTTP Client. The query is
/// specified using generics, which can be explicitly specified
/// but will be picked up in the `variables` parameter.
async fn send_query<T: GraphQLQuery>(
&self,
variables: T::Variables,
authorization: &str
) -> anyhow::Result<GraphQLResponse<T>>;
}
impl GraphQLClient for reqwest::Client {
async fn send_query<T: GraphQLQuery>(
&self,
variables: T::Variables,
token: &str
) -> anyhow::Result<GraphQLResponse<T>> {
self
.post(CANVAS_GRAPHQL_URL)
.bearer_auth(token)
.json(&T::build_query(variables))
.send()
.await
.context("Unable to send GraphQL request")?
.json()
.await
.context("Unable to parse GraphQL response as JSON")
}
}

View file

@ -1,5 +1,59 @@
use anyhow::Context;
use command::canvas_command;
use graphql::{
get_courses::{get_courses::Variables as GetCoursesVariables, GetCourses},
GraphQLClient as _,
};
use graphql_client::GraphQLQuery;
use types::user::CanvasUser;
mod command;
mod graphql;
mod types; mod types;
fn main() { #[tokio::main]
println!("Hello, world!"); async fn main() -> anyhow::Result<()> {
let client = reqwest::Client::builder()
// Only allow secure connections
.https_only(true)
// Mines canvas supports TLSv1.3, only settle for the best
.min_tls_version(reqwest::tls::Version::TLS_1_3)
// Mines canvas supports HTTP/2, no need to do negotiation
.http2_prior_knowledge()
.build()
.expect("Unable to create HTTP client");
match canvas_command().run() {
command::CanvasCommand::Courses { command } => match command {
command::CanvasCoursesSubcommand::List => {
let courses = client
.send_query::<GetCourses>(
GetCoursesVariables,
&keyring::Entry::new(env!("CARGO_PKG_NAME"), "default-token")
.context("Unable to open system credential store")?
.get_password()
.context("No canvas token has been set, log in first")?,
)
.await?;
dbg!(courses);
}
},
command::CanvasCommand::User { command } => match command {
command::CanvasUserSubcommand::Login { token } => {
let user_info: CanvasUser = client
.get(types::user::CANVAS_USER_SELF_ENDPOINT)
.bearer_auth(&token)
.send()
.await?
.json()
.await?;
let entry = keyring::Entry::new(env!("CARGO_PKG_NAME"), "default-token")
.context("Unable to open system credential store")?;
entry.set_password(&token).context("Unable to save canvas token in system credential store")?;
println!("Successfully logged in as user {}", user_info.name);
}
},
};
Ok(())
} }

View file

@ -1,3 +0,0 @@
# GraphQL types
This module is auto-generated with graphql_client_cli, running `for f in ./graphql/queries/*.graphql; do graphql-client generate -d warn -o src/types -s ./graphql/canvas-schema.json $f; done` to generate rust types for each query. Unfortunately, due to there being `id` and `_id` fields in the same type, a manual adjust of the incorrectly-renamed `_id` field is necessary for any types that have both fields.

View file

@ -1,2 +1 @@
pub mod CreateSubmission; pub mod user;
pub mod GetCourseAssignments;

28
src/types/user.rs generated Normal file
View file

@ -0,0 +1,28 @@
#![allow(unused)]
use serde::Deserialize;
use serde::Serialize;
pub const CANVAS_USER_SELF_ENDPOINT: &str = "https://elearning.mines.edu/api/v1/users/self";
#[derive(Debug, Deserialize)]
pub struct CanvasUser {
pub id: u64,
pub name: String,
pub created_at: String,
pub sortable_name: String,
pub short_name: String,
pub pronouns: String,
pub avatar_url: String,
pub last_name: String,
pub first_name: String,
pub locale: String,
pub effective_locale: String,
pub permissions: CanvasUserPermissions,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CanvasUserPermissions {
pub can_update_name: bool,
pub can_update_avatar: bool,
pub limit_parent_app_web_access: bool,
}