diff --git a/Cargo.lock b/Cargo.lock index 5044cbb..1f70db2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3060,6 +3060,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ubisync-lib", + "ubisync-sdk", "uuid", ] @@ -3088,6 +3089,15 @@ dependencies = [ [[package]] name = "ubisync-sdk" version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", + "ubisync-lib", +] [[package]] name = "ucd-trie" diff --git a/Cargo.toml b/Cargo.toml index d0b8d62..d7a187f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +resolver = "2" members = [ "ubisync", diff --git a/ubisync-lib/Cargo.toml b/ubisync-lib/Cargo.toml index 2852b3d..2131140 100644 --- a/ubisync-lib/Cargo.toml +++ b/ubisync-lib/Cargo.toml @@ -10,6 +10,7 @@ axum = { version = "0.7.2", features = [ "macros" ] } chrono = "0.4.31" itertools = "0.12.0" jsonwebtoken = "9.2.0" +reqwest = "0.11.23" serde = { version = "1.0.166", features = [ "derive" ] } serde_json = "1.0.99" serde_with = "3.3.0" diff --git a/ubisync-lib/src/api/app.rs b/ubisync-lib/src/api/app.rs new file mode 100644 index 0000000..eebcb05 --- /dev/null +++ b/ubisync-lib/src/api/app.rs @@ -0,0 +1,28 @@ +use reqwest::Method; +use serde::{Serialize, Deserialize}; + +use super::UbisyncRequest; + + +#[derive(Serialize, Deserialize)] +pub struct AppRegisterRequest { + pub name: String, + pub description: String, +} + +#[derive(Serialize, Deserialize)] +pub struct AppRegisterResponse { + pub token: String, +} + +impl UbisyncRequest for AppRegisterRequest { + type PathParameters = (); + type Response = AppRegisterResponse; + + fn method(&self) -> reqwest::Method { + Method::PUT + } + fn path(&self, _: Self::PathParameters) -> String { + "/app/register".to_string() + } +} \ No newline at end of file diff --git a/ubisync-lib/src/api/element.rs b/ubisync-lib/src/api/element.rs new file mode 100644 index 0000000..782df42 --- /dev/null +++ b/ubisync-lib/src/api/element.rs @@ -0,0 +1,88 @@ +use reqwest::Method; +use serde::{Serialize, Deserialize}; + +use crate::types::{ElementContent, ElementId, Element}; + +use super::UbisyncRequest; + + +#[derive(Serialize, Deserialize)] +pub struct ElementCreateRequest { + pub content: ElementContent, +} + +#[derive(Serialize, Deserialize)] +pub struct ElementCreateResponse { + pub id: ElementId, +} + +impl UbisyncRequest for ElementCreateRequest { + type PathParameters = (); + type Response = ElementCreateResponse; + + fn method(&self) -> Method { + Method::PUT + } + fn path(&self, _: Self::PathParameters) -> String { + "/element".to_string() + } +} + + +#[derive(Serialize, Deserialize)] +pub struct ElementGetRequest; + +#[derive(Serialize, Deserialize)] +pub struct ElementGetResponse { + pub element: Element, +} + +impl UbisyncRequest for ElementGetRequest { + type PathParameters = ElementId; + type Response = ElementGetResponse; + + fn method(&self) -> Method { + Method::GET + } + fn path(&self, params: Self::PathParameters) -> String { + format!("/element/{}", params.to_string()) + } +} + +#[derive(Serialize, Deserialize)] +pub struct ElementSetRequest { + pub content: ElementContent, +} + +#[derive(Serialize, Deserialize)] +pub struct ElementSetResponse; + +impl UbisyncRequest for ElementSetRequest { + type PathParameters = ElementId; + type Response = ElementSetResponse; + + fn method(&self) -> Method { + Method::POST + } + fn path(&self, params: Self::PathParameters) -> String { + format!("/element/{}", serde_json::to_string(¶ms).unwrap()) + } +} + +#[derive(Serialize, Deserialize)] +pub struct ElementRemoveRequest; + +#[derive(Serialize, Deserialize)] +pub struct ElementRemoveResponse; + +impl UbisyncRequest for ElementRemoveRequest { + type PathParameters = ElementId; + type Response = ElementRemoveResponse; + + fn method(&self) -> Method { + Method::DELETE + } + fn path(&self, params: Self::PathParameters) -> String { + format!("/element/{}", serde_json::to_string(¶ms).unwrap()) + } +} \ No newline at end of file diff --git a/ubisync-lib/src/api/mod.rs b/ubisync-lib/src/api/mod.rs new file mode 100644 index 0000000..13ba5a7 --- /dev/null +++ b/ubisync-lib/src/api/mod.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +pub mod app; +pub mod element; + +/// Any struct defining a request body for the ubisync API must implement this trait +/// It is used both by the client in the SDK and by the API logic in the ubisync node +#[async_trait] +pub trait UbisyncRequest: for<'de> Deserialize<'de> + Serialize { + type PathParameters; + type Response: for<'de> Deserialize<'de> + Serialize; + + fn method(&self) -> Method; + fn path(&self, params: Self::PathParameters) -> String; + async fn parse_response(resp: reqwest::Response) -> Result + where + for<'de> ::Response: Deserialize<'de>, + { + resp.json().await + } +} diff --git a/ubisync-lib/src/lib.rs b/ubisync-lib/src/lib.rs index 8ed7f46..b06f1fb 100644 --- a/ubisync-lib/src/lib.rs +++ b/ubisync-lib/src/lib.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod messages; -pub mod types; pub mod peer; +pub mod types; diff --git a/ubisync-sdk/Cargo.toml b/ubisync-sdk/Cargo.toml index 244cf1c..1280caf 100644 --- a/ubisync-sdk/Cargo.toml +++ b/ubisync-sdk/Cargo.toml @@ -6,3 +6,11 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.79" +reqwest = { version = "0.11.23", features = [ "json" ] } +serde = { version = "1.0.166", features = [ "derive" ] } +serde_json = "1.0.99" +tracing = "0.1.37" +tracing-subscriber = "0.3.17" + +ubisync-lib = { path = "../ubisync-lib" } \ No newline at end of file diff --git a/ubisync-sdk/src/error.rs b/ubisync-sdk/src/error.rs new file mode 100644 index 0000000..cbb3a0d --- /dev/null +++ b/ubisync-sdk/src/error.rs @@ -0,0 +1,20 @@ +use std::fmt::Display; + + + +#[derive(Debug)] +pub enum UbisyncError { + InvalidNodeReply(String), + AppRegistrationFailed, +} + +impl Display for UbisyncError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidNodeReply(msg) => write!(f, "Invalid reply from ubisync node: {}", msg), + Self::AppRegistrationFailed => write!(f, "Registrating this app at the ubisync node failed."), + } + } +} + +impl std::error::Error for UbisyncError {} \ No newline at end of file diff --git a/ubisync-sdk/src/lib.rs b/ubisync-sdk/src/lib.rs index e69de29..e856329 100644 --- a/ubisync-sdk/src/lib.rs +++ b/ubisync-sdk/src/lib.rs @@ -0,0 +1,118 @@ +use anyhow::anyhow; +use error::UbisyncError; +use reqwest::{Client, StatusCode}; +use ubisync_lib::api::{ + app::{AppRegisterRequest, AppRegisterResponse}, + UbisyncRequest, +}; +pub use ubisync_lib::*; + +pub mod error; + +pub struct UbisyncClient { + host: String, + port: u16, + selected_api_version: String, + base_url: String, + jwt_token: String, + reqwest_client: Client, +} + +impl UbisyncClient { + pub async fn init( + host: &str, + port: u16, + jwt_token: Option<&str>, + application_name: &str, + application_description: &str, + ) -> Result { + let http_client = Client::new(); + let mut node_api_versions = http_client + .get(&format!("http://{}:{}/versions", host, port)) + .send() + .await + .expect("Failed to contact ubisync node, it may be offline.") + .json::>() + .await + .expect("Failed to read ubisync node's available API versions."); + + node_api_versions.sort(); + let selected_version = node_api_versions + .get(0) + .expect("No available API version returned by ubisync node"); + + let token = match jwt_token { + Some(t) => t.to_string(), + None => { + let response = http_client + .put(Self::build_base_url(host, port, &selected_version) + "/app/register") + .json(&AppRegisterRequest { + name: application_name.to_string(), + description: application_description.to_string(), + }) + .send() + .await + .expect("App registration request failed."); + if response.status() != StatusCode::OK { + return Err(UbisyncError::AppRegistrationFailed); + } + response + .json::() + .await + .expect("Failed to extract JWT from app regstration request") + .token + } + }; + + Ok(UbisyncClient { + host: host.to_string(), + port: port, + selected_api_version: selected_version.to_string(), + base_url: Self::build_base_url(host, port, selected_version), + jwt_token: token.to_string(), + reqwest_client: http_client, + }) + } + + pub async fn send( + &self, + request: R, + parameters: R::PathParameters, + ) -> anyhow::Result + where + R: UbisyncRequest, + { + self.reqwest_client + .request( + request.method(), + &(self.base_url.to_owned() + &request.path(parameters)), + ) + .bearer_auth(&self.jwt_token) + .json(&request) + .send() + .await + .map_err(|e| anyhow!(e))? + .json::() + .await + .map_err(|e| anyhow!(e)) + } + + pub async fn set_host(&mut self, host: String) { + self.host = host; + self.base_url = Self::build_base_url(&self.host, self.port, &self.selected_api_version); + } + + pub async fn set_port(&mut self, port: u16) { + self.port = port; + self.base_url = Self::build_base_url(&self.host, self.port, &self.selected_api_version); + } + + pub async fn set_api_version(&mut self, version: String) { + self.selected_api_version = version; + self.base_url = Self::build_base_url(&self.host, self.port, &self.selected_api_version); + } + + fn build_base_url(host: &str, port: u16, api_version: &str) -> String { + format!("http://{}:{}/{}", host, port, api_version) + } +} diff --git a/ubisync/Cargo.toml b/ubisync/Cargo.toml index e447b1c..386ffdd 100644 --- a/ubisync/Cargo.toml +++ b/ubisync/Cargo.toml @@ -25,4 +25,5 @@ ubisync-lib = { path = "../ubisync-lib" } [dev-dependencies] -reqwest = { version = "0.11.20", features = [ "json" ] } \ No newline at end of file +reqwest = { version = "0.11.20", features = [ "json" ] } +ubisync-sdk = { path = "../ubisync-sdk" } \ No newline at end of file diff --git a/ubisync/src/api/mod.rs b/ubisync/src/api/mod.rs index 40c999d..34db9dc 100644 --- a/ubisync/src/api/mod.rs +++ b/ubisync/src/api/mod.rs @@ -1,4 +1,4 @@ -use axum::Router; +use axum::{Router, routing::get, response::{Response, IntoResponse}, http::StatusCode, Json}; use tokio::{net::TcpListener, task::JoinHandle}; use crate::{config::ApiConfig, state::ApiState}; @@ -44,6 +44,8 @@ impl From for ApiBuilder { impl ApiBuilder { pub async fn build(&self, state: ApiState) -> Api { let mut app: Router = Router::new(); + app = app.route("/versions", get(list_available_versions)); + match &self.version { Some(v) if v == "v0" => app = app.nest(&format!("/{}", v), v0::get_router(state)), _ => app = app.nest("/v0", v0::get_router(state)), @@ -72,3 +74,8 @@ impl ApiBuilder { } } } + + +async fn list_available_versions() -> Response { + (StatusCode::OK, Json {0: vec!["v0"]}).into_response() +} \ No newline at end of file diff --git a/ubisync/src/api/v0/app.rs b/ubisync/src/api/v0/app.rs index ddb12c0..f2eed7d 100644 --- a/ubisync/src/api/v0/app.rs +++ b/ubisync/src/api/v0/app.rs @@ -11,11 +11,11 @@ use axum::{ use jsonwebtoken::{decode, Header}; use serde::{Deserialize, Serialize}; use tracing::{debug, error, warn}; +use ubisync_lib::api::app::{AppRegisterRequest, AppRegisterResponse}; use uuid::Uuid; use crate::state::ApiState; - #[derive(Serialize, Deserialize, Clone, Debug)] pub struct AppId(Uuid); @@ -30,15 +30,6 @@ struct JWTClaims { sub: AppId, } - - -#[derive(Serialize, Deserialize)] -pub struct AppDescription { - pub name: String, - pub desc_text: String, -} - - pub(super) async fn auth( s: Extension>, mut request: Request, @@ -67,12 +58,12 @@ pub(super) async fn auth( pub(super) async fn register( s: Extension>, - Json(data): Json, + Json(body): Json, ) -> Response { // Maybe ask for consent by user // If user wants registration, proceed - let result = s.add_app(&data); + let result = s.add_app(&body.name, &body.description); match result { Ok(id) => { @@ -83,7 +74,13 @@ pub(super) async fn register( &s.jwt_encoding_key(), ); match jwt { - Ok(token) => (StatusCode::OK, token).into_response(), + Ok(token) => ( + StatusCode::OK, + Json { + 0: AppRegisterResponse { token: token }, + }, + ) + .into_response(), Err(e) => { warn!("Failed to encode token: {:?}", e); StatusCode::INTERNAL_SERVER_ERROR.into_response() diff --git a/ubisync/src/api/v0/element.rs b/ubisync/src/api/v0/element.rs index 04905e9..ba80d47 100644 --- a/ubisync/src/api/v0/element.rs +++ b/ubisync/src/api/v0/element.rs @@ -6,17 +6,28 @@ use axum::{ response::{IntoResponse, Response}, Extension, }; -use tracing::{debug, warn}; +use tracing::debug; use crate::state::ApiState; -use ubisync_lib::types::{ElementContent, ElementId}; +use ubisync_lib::{ + api::element::{ + ElementCreateRequest, ElementCreateResponse, ElementGetResponse, ElementSetRequest, + ElementSetResponse, ElementRemoveResponse, + }, + types::ElementId, +}; pub(super) async fn get(Path(id): Path, s: Extension>) -> Response { let element = s.get_element(&id); match element { - Ok(el) => (StatusCode::OK, Json { 0: el }).into_response(), - Err(e) => { - warn!("Element not found:\n{:?}", e); + Ok(el) => ( + StatusCode::OK, + Json { + 0: ElementGetResponse { element: el }, + }, + ) + .into_response(), + Err(_) => { StatusCode::NOT_FOUND.into_response() } } @@ -24,15 +35,15 @@ pub(super) async fn get(Path(id): Path, s: Extension>) pub(super) async fn create( s: Extension>, - Json(content): Json, + Json(req): Json, ) -> Response { - let element_id = s.create_element(&content); + let element_id = s.create_element(&req.content); debug!("{:?}", element_id); match element_id { Ok(id) => ( StatusCode::OK, Json { - 0: &Into::::into(&id), + 0: ElementCreateResponse { id }, }, ) .into_response(), @@ -43,11 +54,17 @@ pub(super) async fn create( pub(super) async fn set( Path(id): Path, s: Extension>, - Json(content): Json, + Json(req): Json, ) -> Response { - let res = s.write_element_content(&id, &content); + let res = s.write_element_content(&id, &req.content); match res { - Ok(_) => StatusCode::OK.into_response(), + Ok(_) => ( + StatusCode::OK, + Json { + 0: ElementSetResponse, + }, + ) + .into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } @@ -55,7 +72,7 @@ pub(super) async fn set( pub(super) async fn remove(Path(id): Path, s: Extension>) -> Response { let res = s.remove_element(&id); match res { - Ok(_) => StatusCode::OK.into_response(), + Ok(_) => (StatusCode::OK, Json { 0: ElementRemoveResponse }).into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } diff --git a/ubisync/src/state/api_state.rs b/ubisync/src/state/api_state.rs index 5dbaac9..4b33748 100644 --- a/ubisync/src/state/api_state.rs +++ b/ubisync/src/state/api_state.rs @@ -6,7 +6,7 @@ use jsonwebtoken::{DecodingKey, EncodingKey, Validation}; use tracing::debug; use ubisync_lib::{types::{ElementContent, ElementId, Element}, messages::MessageContent}; -use crate::{api::v0::app::{AppDescription, AppId}, state::queries}; +use crate::{api::v0::app::AppId, state::queries}; use super::State; @@ -29,15 +29,15 @@ impl ApiState { } } - pub fn add_app(&self, description: &AppDescription) -> anyhow::Result { + pub fn add_app(&self, name: &str, description: &str) -> anyhow::Result { let id = AppId::new(); let last_access = Utc::now(); queries::apps::add( self.db(), &id, &last_access, - &description.name, - &description.desc_text, + name, + description, )?; debug!("Successfully added app"); diff --git a/ubisync/tests/api.rs b/ubisync/tests/api.rs index 765930c..367d389 100644 --- a/ubisync/tests/api.rs +++ b/ubisync/tests/api.rs @@ -1,9 +1,12 @@ use std::time::Duration; -use tracing::{debug, Level}; -use ubisync::{config::Config, Ubisync, api::v0::app::AppDescription}; -use ubisync_lib::types::{ElementContent, ElementId, Element}; - +use tracing::{debug, warn, Level}; +use ubisync::{config::Config, Ubisync}; +use ubisync_lib::{ + api::element::{ElementCreateRequest, ElementGetRequest}, + types::{Element, ElementContent}, +}; +use ubisync_sdk::UbisyncClient; #[tokio::test(flavor = "multi_thread")] async fn two_nodes_element_creation() { @@ -11,6 +14,7 @@ async fn two_nodes_element_creation() { .pretty() .with_max_level(Level::DEBUG) .init(); + // Two nodes need to bind to different ports let mut c2 = Config::default(); c2.api_config.port = Some(9982); @@ -19,63 +23,35 @@ async fn two_nodes_element_creation() { ubi1.add_peer_from_id(ubi2.get_destination().unwrap().into()) .unwrap(); - let http_client = reqwest::Client::new(); - let register_response = http_client - .put("http://localhost:9981/v0/app/register") - .json(&AppDescription { - name: "Test".to_string(), - desc_text: "desc".to_string(), - }) - .send() + let api_client1 = UbisyncClient::init("localhost", 9981, None, "App", "Long desc") .await .unwrap(); - let jwt1 = register_response - .text() - .await - .expect("Couldn't fetch token from response"); - let register_response = http_client - .put("http://localhost:9982/v0/app/register") - .json(&AppDescription { - name: "Test".to_string(), - desc_text: "desc".to_string(), - }) - .send() + let api_client2 = UbisyncClient::init("localhost", 9982, None, "App", "Long desc") .await .unwrap(); - let jwt2 = register_response - .text() - .await - .expect("Couldn't fetch token from response"); let test_element_content = ElementContent::Text("Text".to_string()); - let put_resp = http_client - .put(&format!("http://localhost:9981/v0/element")) - .json(&test_element_content) - .header("Authorization", &format!("Bearer {}", &jwt1)) - .send() + let create_resp = api_client1 + .send( + ElementCreateRequest { + content: test_element_content.clone(), + }, + (), + ) .await .unwrap(); - debug!("{:?}", &put_resp); - let put_resp_text = put_resp.text().await.expect("No put response body"); - debug!("{}", put_resp_text); - let id = - serde_json::from_str::(&put_resp_text).expect("Could not deserialize ElementId"); + let id = create_resp.id; - tokio::time::sleep(Duration::from_millis(3000)).await; + tokio::time::sleep(Duration::from_millis(1000)).await; - let get_resp = http_client - .get(&format!( - "http://localhost:9982/v0/element/{}", - Into::::into(&id) - )) - .header("Authorization", &format!("Bearer {}", &jwt2)) - .send() - .await - .expect("Get request failed"); - let get_resp_text = get_resp.text().await.expect("No get request body"); - debug!("{}", get_resp_text); - let received_element = - serde_json::from_str::(&get_resp_text).expect("Could not deserialize Element"); + let mut get_resp = api_client2.send(ElementGetRequest {}, id.clone()).await; + while let Err(_) = get_resp { + warn!("Sleeping for another second, element has not arrived yet"); + tokio::time::sleep(Duration::from_millis(1000)).await; + get_resp = api_client2.send(ElementGetRequest {}, id.clone()).await; + } + + let received_element: Element = get_resp.unwrap().element; debug!("Other node received this element: {:?}", received_element); assert_eq!(&test_element_content, received_element.content());