woooooooo
This commit is contained in:
parent
7cea0255b4
commit
b0e785a2fc
13 changed files with 1554 additions and 25 deletions
1296
Cargo.lock
generated
1296
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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
2
graphql.config.yml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
schema: ./graphql/canvas-schema.json
|
||||||
|
documents: ./graphql/queries/*.graphql
|
12
graphql/queries/GetCourses.graphql
Normal file
12
graphql/queries/GetCourses.graphql
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
query GetCourses {
|
||||||
|
allCourses {
|
||||||
|
name
|
||||||
|
courseCode
|
||||||
|
courseNickname
|
||||||
|
term {
|
||||||
|
name
|
||||||
|
startAt
|
||||||
|
endAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
src/command.rs
Normal file
35
src/command.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")]
|
|
@ -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,
|
58
src/graphql/get_courses.rs
Normal file
58
src/graphql/get_courses.rs
Normal 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
44
src/graphql/mod.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
58
src/main.rs
58
src/main.rs
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
|
|
@ -1,2 +1 @@
|
||||||
pub mod CreateSubmission;
|
pub mod user;
|
||||||
pub mod GetCourseAssignments;
|
|
28
src/types/user.rs
generated
Normal file
28
src/types/user.rs
generated
Normal 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,
|
||||||
|
}
|
Loading…
Reference in a new issue