use crate::*;
use async_trait::async_trait;
use bytes::Buf;
use endbasic_std::console::remove_control_chars;
use reqwest::header::HeaderMap;
use reqwest::Response;
use reqwest::StatusCode;
use std::cell::RefCell;
use std::io;
use std::rc::Rc;
use std::str;
use url::Url;
async fn http_response_to_io_error(response: Response) -> io::Error {
let status = response.status();
let kind = match status {
StatusCode::OK => panic!("Should not have been called on a successful request"),
StatusCode::BAD_REQUEST => io::ErrorKind::InvalidInput,
StatusCode::FORBIDDEN => io::ErrorKind::PermissionDenied,
StatusCode::INSUFFICIENT_STORAGE => io::ErrorKind::Other,
StatusCode::INTERNAL_SERVER_ERROR => io::ErrorKind::Other,
StatusCode::NOT_FOUND => io::ErrorKind::NotFound,
StatusCode::PAYLOAD_TOO_LARGE => io::ErrorKind::InvalidInput,
StatusCode::SERVICE_UNAVAILABLE => io::ErrorKind::AddrNotAvailable,
StatusCode::UNAUTHORIZED => io::ErrorKind::PermissionDenied,
_ => io::ErrorKind::Other,
};
match response.text().await {
Ok(text) => match serde_json::from_str::<ErrorResponse>(&text) {
Ok(response) => io::Error::new(
kind,
format!("{} (server code: {})", remove_control_chars(response.message), status),
),
_ => io::Error::new(
kind,
format!(
"HTTP request returned status {} with text '{}'",
status,
remove_control_chars(text)
),
),
},
Err(e) => io::Error::new(
kind,
format!(
"HTTP request returned status {} and failed to get text due to {}",
status,
remove_control_chars(e.to_string())
),
),
}
}
fn reqwest_error_to_io_error(e: reqwest::Error) -> io::Error {
io::Error::new(io::ErrorKind::Other, format!("{}", e))
}
struct AuthData {
username: String,
access_token: AccessToken,
}
#[cfg_attr(test, derive(Clone))]
pub struct CloudService {
api_address: Url,
client: reqwest::Client,
auth_data: Rc<RefCell<Option<AuthData>>>,
}
impl CloudService {
pub fn new(api_address: &str) -> io::Result<Self> {
let url = match Url::parse(api_address) {
Ok(url) => url,
Err(e) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Invalid base API address: {}", e),
))
}
};
if !(url.path().is_empty() || url.path() == "/") {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid base API address: cannot contain a path".to_owned(),
));
}
let auth_data = Rc::from(RefCell::from(None));
Ok(Self { api_address: url, client: reqwest::Client::default(), auth_data })
}
fn make_url(&self, path: &str) -> Url {
assert!(path.starts_with("api/"));
let mut url = self.api_address.clone();
assert!(url.path().is_empty() || url.path() == "/");
url.set_path(path);
url
}
fn default_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"x-endbasic-client-version",
env!("CARGO_PKG_VERSION")
.parse()
.expect("Package version should have been serializable"),
);
headers
}
fn require_auth_data(data: Option<&AuthData>) -> io::Result<&AuthData> {
match data.as_ref() {
Some(data) => Ok(data),
None => {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "Not logged in yet".to_owned()))
}
}
}
}
#[async_trait(?Send)]
impl Service for CloudService {
async fn signup(&mut self, request: &SignupRequest) -> io::Result<()> {
let response = self
.client
.post(self.make_url("api/signup"))
.headers(self.default_headers())
.body(serde_json::to_vec(&request)?)
.send()
.await
.map_err(reqwest_error_to_io_error)?;
match response.status() {
reqwest::StatusCode::OK => Ok(()),
_ => Err(http_response_to_io_error(response).await),
}
}
async fn login(&mut self, username: &str, password: &str) -> io::Result<LoginResponse> {
let basic_auth = format!("Basic {}", base64::encode(format!("{}:{}", username, password)));
let response = self
.client
.post(self.make_url("api/login"))
.headers(self.default_headers())
.header("Authorization", basic_auth)
.header("Content-Length", 0)
.send()
.await
.map_err(reqwest_error_to_io_error)?;
match response.status() {
reqwest::StatusCode::OK => {
let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?;
let response: LoginResponse = serde_json::from_reader(bytes.reader())?;
let auth_data = AuthData {
username: username.to_owned(),
access_token: response.access_token.clone(),
};
*(self.auth_data.borrow_mut()) = Some(auth_data);
Ok(response)
}
_ => Err(http_response_to_io_error(response).await),
}
}
async fn logout(&mut self) -> io::Result<()> {
let mut auth_data = self.auth_data.borrow_mut();
let response = {
let auth_data = Self::require_auth_data(auth_data.as_ref())?;
self.client
.post(self.make_url(&format!("api/users/{}/logout", auth_data.username)))
.headers(self.default_headers())
.header("Content-Length", 0)
.bearer_auth(auth_data.access_token.as_str())
.send()
.await
.map_err(reqwest_error_to_io_error)?
};
match response.status() {
reqwest::StatusCode::OK => {
*auth_data = None;
Ok(())
}
_ => Err(http_response_to_io_error(response).await),
}
}
fn is_logged_in(&self) -> bool {
self.auth_data.borrow().is_some()
}
fn logged_in_username(&self) -> Option<String> {
self.auth_data.borrow().as_ref().map(|x| x.username.to_owned())
}
async fn get_files(&mut self, username: &str) -> io::Result<GetFilesResponse> {
let mut builder = self
.client
.get(self.make_url(&format!("api/users/{}/files", username)))
.headers(self.default_headers());
if let Some(auth_data) = self.auth_data.borrow().as_ref() {
builder = builder.bearer_auth(auth_data.access_token.as_str());
}
let response = builder.send().await.map_err(reqwest_error_to_io_error)?;
match response.status() {
reqwest::StatusCode::OK => {
let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?;
let response: GetFilesResponse = serde_json::from_reader(bytes.reader())?;
Ok(response)
}
_ => Err(http_response_to_io_error(response).await),
}
}
async fn get_file(
&mut self,
username: &str,
filename: &str,
request: &GetFileRequest,
) -> io::Result<GetFileResponse> {
let mut builder = self
.client
.get(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
.headers(self.default_headers())
.query(&request);
if let Some(auth_data) = self.auth_data.borrow().as_ref() {
builder = builder.bearer_auth(auth_data.access_token.as_str());
}
let response = builder.send().await.map_err(reqwest_error_to_io_error)?;
match response.status() {
reqwest::StatusCode::OK => {
let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?;
let response: GetFileResponse = serde_json::from_reader(bytes.reader())?;
Ok(response)
}
_ => Err(http_response_to_io_error(response).await),
}
}
async fn patch_file(
&mut self,
username: &str,
filename: &str,
request: &PatchFileRequest,
) -> io::Result<()> {
let auth_data = self.auth_data.borrow();
let response = self
.client
.patch(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
.headers(self.default_headers())
.body(serde_json::to_vec(&request)?)
.bearer_auth(Self::require_auth_data(auth_data.as_ref())?.access_token.as_str())
.send()
.await
.map_err(reqwest_error_to_io_error)?;
match response.status() {
reqwest::StatusCode::OK | reqwest::StatusCode::CREATED => Ok(()),
_ => Err(http_response_to_io_error(response).await),
}
}
async fn delete_file(&mut self, username: &str, filename: &str) -> io::Result<()> {
let auth_data = self.auth_data.borrow();
let response = self
.client
.delete(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
.headers(self.default_headers())
.header("Content-Length", 0)
.bearer_auth(Self::require_auth_data(auth_data.as_ref())?.access_token.as_str())
.send()
.await
.map_err(reqwest_error_to_io_error)?;
match response.status() {
reqwest::StatusCode::OK => Ok(()),
_ => Err(http_response_to_io_error(response).await),
}
}
}
#[cfg(test)]
mod testutils {
use super::*;
use std::env;
pub(crate) fn new_service_from_env() -> CloudService {
let service_api = env::var("SERVICE_URL").expect("Expected env config not found");
CloudService::new(&service_api).unwrap()
}
pub(crate) struct TestContext {
service: CloudService,
username: Option<String>,
files_to_delete: Vec<String>,
}
impl TestContext {
pub(crate) fn new_from_env() -> Self {
TestContext { service: new_service_from_env(), username: None, files_to_delete: vec![] }
}
pub(crate) fn service(&self) -> CloudService {
self.service.clone()
}
pub(crate) fn get_username(&self, i: u8) -> String {
env::var(format!("TEST_ACCOUNT_{}_USERNAME", i)).expect("Expected env config not found")
}
pub(crate) async fn do_login(&mut self, i: u8) -> String {
let username = self.get_username(i);
let password = env::var(format!("TEST_ACCOUNT_{}_PASSWORD", i))
.expect("Expected env config not found");
let _response = self.service.login(&username, &password).await.unwrap();
self.username = Some(username.clone());
username
}
pub(crate) async fn do_logout(&mut self) {
self.service.logout().await.unwrap();
self.username = None;
}
pub(crate) fn random_file(&mut self) -> (String, String) {
let filename = format!("file-{}", rand::random::<u64>());
let content = format!("Test content for {}", filename);
self.files_to_delete.push(filename.clone());
(filename, content)
}
}
impl Drop for TestContext {
fn drop(&mut self) {
#[tokio::main]
#[allow(clippy::single_match)]
async fn cleanup(context: &mut TestContext) {
match context.username.as_ref() {
Some(username) => {
for filename in context.files_to_delete.iter() {
if let Err(e) = context.service.delete_file(username, filename).await {
eprintln!(
"Failed to delete file {} during cleanup: {}",
filename, e
);
}
}
if let Err(e) = context.service.logout().await {
eprintln!("Failed to log out for {} during cleanup: {}", username, e);
}
}
_ => {
}
}
}
cleanup(self);
}
}
}
#[cfg(test)]
mod tests {
use super::testutils::*;
use super::*;
use std::env;
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_login_ok() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let _username = context.do_login(1).await;
}
run(&mut TestContext::new_from_env());
}
#[tokio::test]
#[ignore = "Requires environment configuration and is expensive"]
async fn test_login_bad_password() {
let username = env::var("TEST_ACCOUNT_1_USERNAME").expect("Expected env config not found");
let password = "this is an invalid password for the test account";
let mut service = new_service_from_env();
let err = service.login(&username, &password).await.unwrap_err();
assert_eq!(io::ErrorKind::PermissionDenied, err.kind());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_get_files() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let username = context.do_login(1).await;
let mut service = context.service();
let mut needed_bytes = 0;
let mut needed_files = 0;
let mut filenames_and_contents = vec![];
for _ in 0..5 {
let (filename, content) = context.random_file();
needed_bytes += content.as_bytes().len() as u64;
needed_files += 1;
filenames_and_contents.push((filename, content));
}
let response = service.get_files(&username).await.unwrap();
for (filename, _content) in &filenames_and_contents {
assert!(!response.files.iter().any(|x| &x.filename == filename));
}
let disk_quota: DiskSpace = response.disk_quota.unwrap().into();
let disk_free: DiskSpace = response.disk_free.unwrap().into();
assert!(disk_quota.bytes() > 0);
assert!(disk_quota.files() > 0);
assert!(disk_free.bytes() >= needed_bytes, "Not enough space for test run");
assert!(disk_free.files() >= needed_files, "Not enough space for test run");
for (filename, _content) in &filenames_and_contents {
let request = GetFileRequest::default().with_get_content();
let err = service.get_file(&username, filename, &request).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
}
for (filename, content) in &filenames_and_contents {
let request = PatchFileRequest::default().with_content(content.as_bytes());
service.patch_file(&username, filename, &request).await.unwrap();
}
let response = service.get_files(&username).await.unwrap();
for (filename, _content) in &filenames_and_contents {
assert!(response.files.iter().any(|x| &x.filename == filename));
}
}
run(&mut TestContext::new_from_env());
}
async fn do_get_and_patch_file_test(context: &mut TestContext, filename: &str, content: &[u8]) {
let username = context.do_login(1).await;
let mut service = context.service();
let request = PatchFileRequest::default().with_content(content);
service.patch_file(&username, filename, &request).await.unwrap();
let request = GetFileRequest::default().with_get_content();
let response = service.get_file(&username, filename, &request).await.unwrap();
assert_eq!(content, response.decoded_content().unwrap().unwrap());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_get_and_patch_file_ok() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let (filename, content) = context.random_file();
do_get_and_patch_file_test(context, &filename, content.as_bytes()).await;
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_get_and_patch_file_empty_ok() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let (filename, _content) = context.random_file();
do_get_and_patch_file_test(context, &filename, &[]).await;
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_get_and_patch_file_utf8() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let (filename, _content) = context.random_file();
let content = "안녕하세요";
do_get_and_patch_file_test(context, &filename, content.as_bytes()).await;
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_get_file_not_found() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let username = context.do_login(1).await;
let mut service = context.service();
let (filename, _content) = context.random_file();
let request = GetFileRequest::default().with_get_content();
let err = service.get_file(&username, &filename, &request).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_patch_file_without_login() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let mut service = context.service();
let username = context.get_username(1);
context.do_login(1).await;
let (filename, _content) = context.random_file();
context.do_logout().await;
let request = PatchFileRequest::default().with_content("foo");
let err = service.patch_file(&username, &filename, &request).await.unwrap_err();
assert_eq!(io::ErrorKind::PermissionDenied, err.kind(), "{}", err);
assert!(format!("{}", err).contains("Not logged in"));
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_acls_private() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let mut service = context.service();
let (filename, content) = context.random_file();
let username1 = context.get_username(1);
let username2 = context.get_username(2);
let request = PatchFileRequest::default().with_content(content.clone());
context.do_login(1).await;
service.patch_file(&username1, &filename, &request).await.unwrap();
context.do_login(2).await;
let request = GetFileRequest::default().with_get_content();
let err = service.get_file(&username1, &filename, &request).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
context.do_login(1).await;
let request = PatchFileRequest::default().with_add_readers([username2]);
service.patch_file(&username1, &filename, &request).await.unwrap();
context.do_login(2).await;
let request = GetFileRequest::default().with_get_content();
let response = service.get_file(&username1, &filename, &request).await.unwrap();
assert_eq!(content.as_bytes(), response.decoded_content().unwrap().unwrap());
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_acls_public() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let mut service = context.service();
let (filename, content) = context.random_file();
let username1 = context.get_username(1);
let request = PatchFileRequest::default().with_content(content.clone());
context.do_login(1).await;
service.patch_file(&username1, &filename, &request).await.unwrap();
context.do_logout().await;
let request = GetFileRequest::default().with_get_content();
let err = service.get_file(&username1, &filename, &request).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
context.do_login(1).await;
let request = PatchFileRequest::default().with_add_readers(["public".to_owned()]);
service.patch_file(&username1, &filename, &request).await.unwrap();
context.do_logout().await;
let request = GetFileRequest::default().with_get_content();
let response = service.get_file(&username1, &filename, &request).await.unwrap();
assert_eq!(content.as_bytes(), response.decoded_content().unwrap().unwrap());
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_delete_file_ok() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let username = context.do_login(1).await;
let mut service = context.service();
let (filename, content) = context.random_file();
let request = PatchFileRequest::default().with_content(content);
service.patch_file(&username, &filename, &request).await.unwrap();
service.delete_file(&username, &filename).await.unwrap();
let request = GetFileRequest::default().with_get_content();
let err = service.get_file(&username, &filename, &request).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
assert!(format!("{}", err).contains("(server code: 404"));
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_delete_file_not_found() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let username = context.do_login(1).await;
let mut service = context.service();
let (filename, _content) = context.random_file();
let err = service.delete_file(&username, &filename).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
assert!(format!("{}", err).contains("(server code: 404"));
}
run(&mut TestContext::new_from_env());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_delete_file_without_login() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let mut service = context.service();
let username = context.get_username(1);
context.do_login(1).await;
let (filename, _content) = context.random_file();
context.do_logout().await;
let err = service.delete_file(&username, &filename).await.unwrap_err();
assert_eq!(io::ErrorKind::PermissionDenied, err.kind(), "{}", err);
assert!(format!("{}", err).contains("Not logged in"));
}
run(&mut TestContext::new_from_env());
}
}