usos-rs 1.0.0

Crate provides a convenient way to authenticate to USOS api
Documentation
//! # usos-rs
//!
//! The `usos-rs` crate provides a convenient way to authenticate to [USOS api](https://apps.usos.edu.pl)
//!
//! ## Getting first name of the user
//! ```rust
//! use reqwest::Url;
//! use std::error::Error;
//! use std::{collections::HashSet, io};
//! use usos_rs::{self, Scope, UsosRs};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn Error>> {
//!     let consumer_key = "consumer key";
//!     let consumer_secret = "consumer secret";
//!     let api_url = Url::parse("api url")?;
//!     let usos = UsosRs::new(api_url, consumer_key.to_owned(), consumer_secret.to_owned())?;
//!
//!     let scopes = HashSet::from([Scope::PersonalData]);
//!     let u_user = usos.get_unauthorized_user(Some(&scopes)).await?;
//!
//!     println!("{}", u_user.authorize_url());
//!     let mut user_input = String::new();
//!     io::stdin().read_line(&mut user_input)?;
//!     let pin = user_input.trim();
//!     let user = u_user.authorize(pin).await.map_err(|(_, e)| e)?;
//!
//!     let response = user
//!         .reqwest_client()
//!         .post(user.api_url().join("/services/users/user")?)
//!         .query(&[("fields", "first_name")])
//!         .send()
//!         .await?;
//!
//!     println!("{}", response.text().await?);
//!
//!     Ok(())
//! }
//! ```
//!
//! ## Getting first name of already authorized user
//! ```rust
//! use reqwest::Url;
//! use std::error::Error;
//! use usos_rs::{self, AccessToken, UsosRs};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn Error>> {
//!     let consumer_key = "consumer key";
//!     let consumer_secret = "consumer secret";
//!     let api_url = Url::parse("api url")?;
//!     let usos = UsosRs::new(api_url, consumer_key.to_owned(), consumer_secret.to_owned())?;
//!
//!     let user = usos
//!         .get_authorized_user(AccessToken {
//!             token: "access token".to_owned(),
//!             token_secret: "access token secret".to_owned(),
//!         })
//!         .await;
//!
//!     let response = user
//!         .reqwest_client()
//!         .post(user.api_url().join("/services/users/user")?)
//!         .query(&[("fields", "first_name")])
//!         .send()
//!         .await?;
//!
//!     println!("{}", response.text().await?);
//!
//!     Ok(())
//! }
//! ```

use itertools::Itertools;
use oauth1_request::signature_method::HmacSha1;
use reqwest::{self, Url};
use reqwest_oauth1::{OAuthClientProvider, Secrets, SecretsProvider, TokenReaderFuture};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use std::collections::HashSet;

const REQUEST_TOKEN_URL_SUFFIX: &str = "/services/oauth/request_token";
const AUTHORIZE_TOKEN_URL_SUFFIX: &str = "/services/oauth/authorize";
const ACCESS_TOKEN_URL_SUFFIX: &str = "/services/oauth/access_token";

type ReqwestOauth1Client<'a> =
	reqwest_oauth1::Client<reqwest_oauth1::Signer<'a, reqwest_oauth1::Secrets<'a>, HmacSha1>>;

#[derive(Debug)]
pub struct UsosRs<'a> {
	authorize_url: Url,
	request_token_url: Url,
	access_token_url: Url,
	callback_url: Option<Url>,
	secrets: Secrets<'a>,
	reqwest_client: ReqwestOauth1Client<'a>,
	api_url: Url,
}

impl<'a> UsosRs<'a> {
	pub fn new(
		api_url: Url,
		consumer_key: String,
		consumer_secret: String,
	) -> Result<UsosRs<'a>, url::ParseError> {
		let request_token_url = api_url.join(REQUEST_TOKEN_URL_SUFFIX)?;
		let authorize_url = api_url.join(AUTHORIZE_TOKEN_URL_SUFFIX)?;
		let access_token_url = api_url.join(ACCESS_TOKEN_URL_SUFFIX)?;
		let secrets = reqwest_oauth1::Secrets::new(consumer_key, consumer_secret);
		let client = reqwest::Client::new().oauth1(secrets.clone());
		Ok(UsosRs {
			access_token_url,
			request_token_url,
			authorize_url,
			reqwest_client: client,
			secrets,
			callback_url: None,
			api_url,
		})
	}

	pub async fn get_unauthorized_user(
		&'a self,
		scopes: Option<&HashSet<Scope>>,
	) -> Result<UnauthorizedUser<'a>, reqwest_oauth1::Error> {
		let callback_url = self.callback_url.as_ref().map(|x| x.as_str());
		let mut query = vec![("oauth_callback", callback_url.unwrap_or("oob"))];
		let scopes_text: String;
		if let Some(scopes) = scopes {
			scopes_text =
				Itertools::intersperse(scopes.iter().map(|x| x.api_name()), "|").collect();

			query.push(("scopes", &scopes_text))
		}

		let resp = self
			.reqwest_client
			.post(self.request_token_url.clone())
			.query(&query)
			.send()
			.parse_oauth_token()
			.await?;

		let mut authorize_url = self.authorize_url.clone();
		authorize_url
			.query_pairs_mut()
			.append_pair("oauth_token", &resp.oauth_token);

		let secrets = self
			.secrets
			.clone()
			.token(resp.oauth_token, resp.oauth_token_secret);

		Ok(UnauthorizedUser {
			usos_rs: self,
			authorize_url,
			reqwest_client: reqwest::Client::new().oauth1(secrets),
		})
	}

	pub async fn get_authorized_user(&'a self, access_token: AccessToken) -> User<'a> {
		let secrets = self
			.secrets
			.clone()
			.token(access_token.token, access_token.token_secret);

		User {
			secrets: secrets.clone(),
			reqwest_client: reqwest::Client::new().oauth1(secrets),
			usos_rs: self,
		}
	}

	pub fn callback_url_mut(&'a mut self) -> &'a mut Option<Url> {
		&mut self.callback_url
	}

	pub fn api_url(&'a self) -> &'a Url {
		&self.api_url
	}

	pub fn reqwest_client(&'a self) -> &'a ReqwestOauth1Client {
		&self.reqwest_client
	}
}

/// Scopes which describes the things you want the User to share with you
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
pub enum Scope {
	/// Allows access to get administration documents etc.
	AdministrationDocuments,

	/// Provides access to user's ID cards data, such as chip uid or expiration date
	Cards,

	/// Allows you to change user preferences (via the uprefs module). You may need some other scopes in order to change
	/// or view some of the preferences. Also, the access to some important preferences may be restricted in other ways,
	/// i.e. only Administrative Consumers may be allowed to change them.
	ChangeAllPreferences,

	/// Provides access to details and results of user's course tests.
	CourseTests,

	/// Provides access to administrative housing operations on user's behalf. For more information,
	/// please visit the housing module.
	DormitoryAdmin,

	/// Allows editing user's attributes (the same thet the user can edit on his USOSweb profile page).
	EditUserAttributes,

	/// Provides access to user's email address.
	Email,

	/// Allows access to user's preferences, push notifications, etc.
	Events,

	/// Provides access to grades information.
	Grades,

	/// Allows access to read and write exam reports.
	WriteGrades,

	/// Provides access to the mailclient module (in the name of your user). Currently only a small set of methods is
	/// available for non-administrative consumers, but this set will be growing.
	MailClient,

	/// Provides access to user's personal mobile phone number(s).
	PhoneNumbers,

	/// Enables your application to perform authorized requests on behalf of the user at any time. By default, Access
	/// Tokens expire after a short time period to ensure applications only make requests on behalf of users when they
	/// are actively using the application. This scope makes Access Tokens long-lived.
	OfflineAccess,

	/// Provides access to email addresses of other users (i.e. the ones related to your user).
	OtherEmails,

	/// Allows access to your payments.
	Payments,

	/// Provides access to user's personal data, such as PESEL number, date of birth, etc.
	PersonalData,

	/// Provides read access to user's photo and his/her photo visibility preferences ("who can see my photo?").
	Photo,

	/// Provides access to results of user's placement tests in foreign languages.
	PlacementTests,

	/// (for Administrative Consumers only) Allows access to official permissions related to the user's session
	/// debugging rights. Allows you to get the answer to the question "Is my user permitted to debug the session of
	/// user X?". See "can_i_debug" field of the services/users/user method for more information.
	SessionDebuggingPermissions,

	/// Provides access to most of the actions within the Clearance Slips module. With this scope you can view, create
	/// and edit slips, answer questions and perform any non-administrative action which the user can perform via
	/// USOSweb. You will need an additional 'slips_admin' scope if you want to manage slip templates too.
	Slips,

	/// Provides access to template management of the "slips" module. That is, it allows you to create and edit
	/// questions, mark templates as obsolete etc.
	SlipsAdmin,

	/// If your user is a staff member, then this scope provides access to some common student-related data usually
	/// visible only to staff members, e.g. student numbers, or broader lists of students' study programmes.
	StaffPerspective,

	/// Provides access to lists of student's exams, information on their examiners, places the exams take place etc.
	StudentExams,

	/// Allows to register and unregister the student from his exams.
	StudentExamsWrite,

	/// Provides access to lists of programmes, courses, classes and groups which the user attends (as a student).
	Studies,

	/// Allows access to surveys from students point of view. With this scope you can fetch and fill out surveys.
	SurveysFilling,

	/// Allows access to reports on surveys that concern user as a lecturer.
	SurveysReports,

	/// Allows access to editing diploma exam protocols, e.g. signing protocols.
	ThesesProtocolsWrite,
}

impl Scope {
	const fn api_name(&self) -> &'static str {
		match self {
			Scope::AdministrationDocuments => "adm_documents",
			Scope::Cards => "cards",
			Scope::ChangeAllPreferences => "change_all_preferences",
			Scope::CourseTests => "crstests",
			Scope::DormitoryAdmin => "dorm_admin",
			Scope::EditUserAttributes => "edit_user_attrs",
			Scope::Email => "email",
			Scope::Events => "events",
			Scope::Grades => "grades",
			Scope::WriteGrades => "grades_write",
			Scope::MailClient => "mailclient",
			Scope::PhoneNumbers => "mobile_numbers",
			Scope::OfflineAccess => "offline_access",
			Scope::OtherEmails => "other_emails",
			Scope::Payments => "payments",
			Scope::PersonalData => "personal",
			Scope::Photo => "photo",
			Scope::PlacementTests => "placement_tests",
			Scope::SessionDebuggingPermissions => "session_debugging_perm",
			Scope::Slips => "slips",
			Scope::SlipsAdmin => "slips_admin",
			Scope::StaffPerspective => "staff_perspective",
			Scope::StudentExams => "student_exams",
			Scope::StudentExamsWrite => "student_exams_write",
			Scope::Studies => "studies",
			Scope::SurveysFilling => "surveys_filling",
			Scope::SurveysReports => "surveys_reports",
			Scope::ThesesProtocolsWrite => "theses_protocols_write",
		}
	}
}

#[derive(Debug)]
pub struct UnauthorizedUser<'a> {
	usos_rs: &'a UsosRs<'a>,
	authorize_url: Url,
	reqwest_client: ReqwestOauth1Client<'a>,
}

impl<'a> UnauthorizedUser<'a> {
	pub async fn authorize(self, pin: &str) -> Result<User<'a>, (Self, reqwest_oauth1::Error)> {
		let resp = self
			.reqwest_client
			.get(self.usos_rs.access_token_url.clone())
			.query(&[("oauth_verifier", pin)])
			.send()
			.parse_oauth_token()
			.await;

		let resp = match resp {
			Ok(v) => v,
			Err(e) => return Err((self, e)),
		};

		let secrets = self
			.usos_rs
			.secrets
			.clone()
			.token(resp.oauth_token, resp.oauth_token_secret);

		Ok(User {
			secrets: secrets.clone(),
			reqwest_client: reqwest::Client::new().oauth1(secrets),
			usos_rs: self.usos_rs,
		})
	}

	pub fn authorize_url(&'a self) -> &'a Url {
		&self.authorize_url
	}
}

#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub struct AccessToken {
	pub token: String,
	pub token_secret: String,
}

#[derive(Debug)]
pub struct User<'a> {
	usos_rs: &'a UsosRs<'a>,
	secrets: Secrets<'a>,
	reqwest_client: ReqwestOauth1Client<'a>,
}

impl<'a> User<'a> {
	pub fn access_token(&'a self) -> AccessToken {
		let token_pair = self
			.secrets
			.get_token_pair_option()
			.expect("Should have token pair");

		AccessToken {
			token: token_pair.0.to_owned(),
			token_secret: token_pair.1.to_owned(),
		}
	}

	pub fn reqwest_client(&'a self) -> &'a ReqwestOauth1Client {
		&self.reqwest_client
	}

	pub fn api_url(&'a self) -> &'a Url {
		&self.usos_rs.api_url
	}
}