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]
|
||||
name = "canvas-autouploader"
|
||||
name = "canvas-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.89"
|
||||
bpaf = { version = "0.9.15", features = ["derive"] }
|
||||
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"
|
||||
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)]
|
||||
#[derive(Debug)]
|
||||
pub struct CreateSubmission;
|
||||
pub mod create_submission {
|
||||
#![allow(dead_code)]
|
||||
|
@ -15,38 +16,38 @@ pub mod create_submission {
|
|||
type Int = i64;
|
||||
#[allow(dead_code)]
|
||||
type ID = String;
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Variables {
|
||||
pub file_id: ID,
|
||||
pub assignment_id: ID,
|
||||
}
|
||||
impl Variables {}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ResponseData {
|
||||
#[serde(rename = "createSubmission")]
|
||||
pub create_submission: Option<CreateSubmissionCreateSubmission>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateSubmissionCreateSubmission {
|
||||
pub errors: Option<Vec<CreateSubmissionCreateSubmissionErrors>>,
|
||||
pub submission: Option<CreateSubmissionCreateSubmissionSubmission>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateSubmissionCreateSubmissionErrors {
|
||||
pub message: String,
|
||||
pub attribute: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateSubmissionCreateSubmissionSubmission {
|
||||
pub id: ID,
|
||||
pub assignment: Option<CreateSubmissionCreateSubmissionSubmissionAssignment>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateSubmissionCreateSubmissionSubmissionAssignment {
|
||||
pub name: Option<String>,
|
||||
pub course: Option<CreateSubmissionCreateSubmissionSubmissionAssignmentCourse>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreateSubmissionCreateSubmissionSubmissionAssignmentCourse {
|
||||
pub name: String,
|
||||
#[serde(rename = "courseCode")]
|
|
@ -1,4 +1,5 @@
|
|||
#![allow(clippy::all, warnings)]
|
||||
#[derive(Debug)]
|
||||
pub struct GetCourseAssignments;
|
||||
pub mod get_course_assignments {
|
||||
#![allow(dead_code)]
|
||||
|
@ -15,7 +16,7 @@ pub mod get_course_assignments {
|
|||
type Int = i64;
|
||||
#[allow(dead_code)]
|
||||
type ID = String;
|
||||
#[derive()]
|
||||
#[derive(Debug)]
|
||||
pub enum SubmissionType {
|
||||
attendance,
|
||||
basic_lti_launch,
|
||||
|
@ -76,35 +77,34 @@ pub mod get_course_assignments {
|
|||
}
|
||||
}
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct Variables;
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ResponseData {
|
||||
#[serde(rename = "allCourses")]
|
||||
pub all_courses: Option<Vec<GetCourseAssignmentsAllCourses>>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetCourseAssignmentsAllCourses {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: ID,
|
||||
pub _id: ID,
|
||||
#[serde(rename = "courseCode")]
|
||||
pub course_code: Option<String>,
|
||||
#[serde(rename = "assignmentGroups")]
|
||||
pub assignment_groups: Option<Vec<GetCourseAssignmentsAllCoursesAssignmentGroups>>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetCourseAssignmentsAllCoursesAssignmentGroups {
|
||||
#[serde(rename = "assignmentsConnection")]
|
||||
pub assignments_connection:
|
||||
Option<GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnection>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnection {
|
||||
pub nodes: Option<
|
||||
Vec<Option<GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnectionNodes>>,
|
||||
>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetCourseAssignmentsAllCoursesAssignmentGroupsAssignmentsConnectionNodes {
|
||||
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;
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
#[tokio::main]
|
||||
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 GetCourseAssignments;
|
||||
pub mod user;
|
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