use crate::*;
use async_trait::async_trait;
use base64::prelude::*;
use bytes::Buf;
use endbasic_std::console::remove_control_chars;
use endbasic_std::storage::FileAcls;
use reqwest::Response;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
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::other(format!("{}", e))
}
struct AuthData {
username: String,
access_token: AccessToken,
}
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())
.header("Content-Type", "application/json")
.body(serde_json::to_vec(&request)?)
.send()
.await
.map_err(reqwest_error_to_io_error)?;
match response.status() {
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_STANDARD.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() {
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() {
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() {
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) -> io::Result<Vec<u8>> {
let mut builder = self
.client
.get(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
.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() {
StatusCode::OK => {
Ok(response.bytes().await.map_err(reqwest_error_to_io_error)?.to_vec())
}
_ => Err(http_response_to_io_error(response).await),
}
}
async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result<FileAcls> {
let mut headers = self.default_headers();
headers.insert("X-EndBASIC-GetContent", "false".parse().unwrap());
headers.insert("X-EndBASIC-GetReaders", "true".parse().unwrap());
let mut builder = self
.client
.get(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
.headers(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() {
StatusCode::OK => {
let mut readers = vec![];
for h in response.headers().get_all("X-EndBASIC-Reader") {
match h.to_str() {
Ok(value) => readers.push(value.to_owned()),
Err(e) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Server returned invalid reader ACL: {}", e),
));
}
}
}
let bytes = response.bytes().await.map_err(reqwest_error_to_io_error)?;
debug_assert!(bytes.is_empty(), "Did not expect server to return content");
Ok(FileAcls::default().with_readers(readers))
}
_ => Err(http_response_to_io_error(response).await),
}
}
async fn patch_file_content(
&mut self,
username: &str,
filename: &str,
content: Vec<u8>,
) -> 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())
.header("Content-Type", "application/octet-stream")
.header("X-EndBASIC-PatchContent", "true")
.body(content)
.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() {
StatusCode::OK | StatusCode::CREATED => Ok(()),
_ => Err(http_response_to_io_error(response).await),
}
}
async fn patch_file_acls(
&mut self,
username: &str,
filename: &str,
add: &FileAcls,
remove: &FileAcls,
) -> io::Result<()> {
let auth_data = self.auth_data.borrow();
let mut builder = self
.client
.patch(self.make_url(&format!("api/users/{}/files/{}", username, filename)))
.headers(self.default_headers())
.header("Content-Type", "application/octet-stream")
.header("X-EndBASIC-PatchContent", "false");
for reader in add.readers() {
builder = builder.header("X-EndBASIC-AddReader", reader);
}
for reader in remove.readers() {
builder = builder.header("X-EndBASIC-RemoveReader", reader);
}
let response = builder
.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() {
StatusCode::OK | 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() {
StatusCode::OK => Ok(()),
_ => Err(http_response_to_io_error(response).await),
}
}
}
#[cfg(test)]
mod testutils {
use super::*;
use std::collections::HashMap;
use std::env;
pub struct AutoDeletingService<S: Service> {
service: S,
current_user: Option<(String, String)>,
files_to_delete: HashMap<String, (String, String)>,
}
impl<S: Service> AutoDeletingService<S> {
pub fn new(service: S) -> Self {
Self { service, current_user: None, files_to_delete: HashMap::default() }
}
}
#[async_trait(?Send)]
impl<S: Service> Service for AutoDeletingService<S> {
async fn signup(&mut self, request: &SignupRequest) -> io::Result<()> {
self.service.signup(request).await
}
async fn login(&mut self, username: &str, password: &str) -> io::Result<LoginResponse> {
let result = self.service.login(username, password).await;
if result.is_ok() {
self.current_user = Some((username.to_owned(), password.to_owned()));
}
result
}
async fn logout(&mut self) -> io::Result<()> {
let result = self.service.logout().await;
if result.is_ok() {
self.current_user = None;
}
result
}
fn is_logged_in(&self) -> bool {
self.service.is_logged_in()
}
fn logged_in_username(&self) -> Option<String> {
self.service.logged_in_username()
}
async fn get_files(&mut self, username: &str) -> io::Result<GetFilesResponse> {
self.service.get_files(username).await
}
async fn get_file(&mut self, username: &str, filename: &str) -> io::Result<Vec<u8>> {
self.service.get_file(username, filename).await
}
async fn get_file_acls(&mut self, username: &str, filename: &str) -> io::Result<FileAcls> {
self.service.get_file_acls(username, filename).await
}
async fn patch_file_content(
&mut self,
username: &str,
filename: &str,
content: Vec<u8>,
) -> io::Result<()> {
let result = self.service.patch_file_content(username, filename, content).await;
if result.is_ok() {
self.files_to_delete
.insert(filename.to_owned(), self.current_user.clone().unwrap());
}
result
}
async fn patch_file_acls(
&mut self,
username: &str,
filename: &str,
add: &FileAcls,
remove: &FileAcls,
) -> io::Result<()> {
self.service.patch_file_acls(username, filename, add, remove).await
}
async fn delete_file(&mut self, username: &str, filename: &str) -> io::Result<()> {
let result = self.service.delete_file(username, filename).await;
if result.is_ok() {
self.files_to_delete.remove(filename);
}
result
}
}
impl<S: Service> Drop for AutoDeletingService<S> {
fn drop(&mut self) {
#[tokio::main]
#[allow(clippy::single_match)]
async fn cleanup<S: Service>(service: &mut AutoDeletingService<S>) {
if let Some((username, _password)) = service.current_user.as_ref() {
service
.service
.logout()
.await
.map_err(|e| {
format!("Failed to log out for {} during cleanup: {}", username, e)
})
.unwrap();
}
for (filename, (username, password)) in service.files_to_delete.iter() {
service
.service
.login(username, password)
.await
.map_err(|e| {
format!("Failed to log in for {} during cleanup: {}", username, e)
})
.unwrap();
service
.service
.delete_file(username, filename)
.await
.map_err(|e| {
format!("Failed to delete file {} during cleanup: {}", filename, e)
})
.unwrap();
service
.service
.logout()
.await
.map_err(|e| {
format!("Failed to log out for {} during cleanup: {}", username, e)
})
.unwrap();
}
}
cleanup(self);
}
}
pub(crate) fn new_service_from_env() -> AutoDeletingService<CloudService> {
let service_api = env::var("SERVICE_URL").expect("Expected env config not found");
AutoDeletingService::new(CloudService::new(&service_api).unwrap())
}
pub(crate) struct TestContext {
pub(super) service: AutoDeletingService<CloudService>,
}
impl TestContext {
pub(crate) fn new_from_env() -> Self {
TestContext { service: new_service_from_env() }
}
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();
username
}
pub(crate) async fn do_logout(&mut self) {
self.service.logout().await.unwrap();
}
pub(crate) fn random_file(&mut self) -> (String, Vec<u8>) {
let filename = format!("file-{}", rand::random::<u64>());
let content = format!("Test content for {}", filename);
(filename, content.into_bytes())
}
}
}
#[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());
}
#[test]
#[ignore = "Requires environment configuration and is expensive"]
fn test_login_bad_password() {
#[tokio::main]
async fn run(context: &mut TestContext) {
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 err = context.service.login(&username, password).await.unwrap_err();
assert_eq!(io::ErrorKind::PermissionDenied, err.kind());
}
run(&mut TestContext::new_from_env());
}
#[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 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.len() as u64;
needed_files += 1;
filenames_and_contents.push((filename, content));
}
let response = context.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 err = context.service.get_file(&username, filename).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
}
for (filename, content) in &filenames_and_contents {
context
.service
.patch_file_content(&username, filename, content.clone())
.await
.unwrap();
}
let response = context.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<B: Into<Vec<u8>>>(
context: &mut TestContext,
filename: &str,
content: B,
) {
let username = context.do_login(1).await;
let content = content.into();
context.service.patch_file_content(&username, filename, content.clone()).await.unwrap();
assert_eq!(content, context.service.get_file(&username, filename).await.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).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).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 (filename, _content) = context.random_file();
let err = context.service.get_file(&username, &filename).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 username = context.get_username(1);
context.do_login(1).await;
let (filename, _content) = context.random_file();
context.do_logout().await;
let err = context
.service
.patch_file_content(&username, &filename, b"foo".to_vec())
.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 (filename, content) = context.random_file();
let username1 = context.get_username(1);
let username2 = context.get_username(2);
context.do_login(1).await;
context
.service
.patch_file_content(&username1, &filename, content.clone())
.await
.unwrap();
context.do_login(2).await;
let err = context.service.get_file(&username1, &filename).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
context.do_login(1).await;
context
.service
.patch_file_acls(
&username1,
&filename,
&FileAcls::default().with_readers([username2]),
&FileAcls::default(),
)
.await
.unwrap();
context.do_login(2).await;
let response = context.service.get_file(&username1, &filename).await.unwrap();
assert_eq!(content, response);
}
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 (filename, content) = context.random_file();
let username1 = context.get_username(1);
context.do_login(1).await;
context
.service
.patch_file_content(&username1, &filename, content.clone())
.await
.unwrap();
context.do_logout().await;
let err = context.service.get_file(&username1, &filename).await.unwrap_err();
assert_eq!(io::ErrorKind::NotFound, err.kind(), "{}", err);
context.do_login(1).await;
context
.service
.patch_file_acls(
&username1,
&filename,
&FileAcls::default().with_readers(["public".to_owned()]),
&FileAcls::default(),
)
.await
.unwrap();
context.do_logout().await;
let response = context.service.get_file(&username1, &filename).await.unwrap();
assert_eq!(content, response);
}
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 (filename, content) = context.random_file();
context.service.patch_file_content(&username, &filename, content).await.unwrap();
context.service.delete_file(&username, &filename).await.unwrap();
let err = context.service.get_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_not_found() {
#[tokio::main]
async fn run(context: &mut TestContext) {
let username = context.do_login(1).await;
let (filename, _content) = context.random_file();
let err = context.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 username = context.get_username(1);
context.do_login(1).await;
let (filename, _content) = context.random_file();
context.do_logout().await;
let err = context.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());
}
}