diff options
author | initard <solo@softwareag.com> | 2022-02-01 17:49:53 +0100 |
---|---|---|
committer | initard <solo@softwareag.com> | 2022-02-15 11:43:49 +0000 |
commit | 2411743f17e14fa6031d4940d6a0135abdc00baf (patch) | |
tree | ddc7782f8e72a4a3b4531585ec93779bfaf0c05c /crates/core/c8y_api | |
parent | ae402f67527d022a3cedd60f049d73724079850a (diff) |
c8y_api lib, tedge config, mqtt/http utilities (#790)
Preparing the repo for the log request plugin. Restructuring
folders, moving code out of sm_c8y_mapper and into c8y_api,
c8y_smartrest or tedge_config.
Signed-off-by: initard <solo@softwareag.com>
Diffstat (limited to 'crates/core/c8y_api')
-rw-r--r-- | crates/core/c8y_api/Cargo.toml | 38 | ||||
-rw-r--r-- | crates/core/c8y_api/src/error.rs | 1 | ||||
-rw-r--r-- | crates/core/c8y_api/src/http_proxy.rs | 421 | ||||
-rw-r--r-- | crates/core/c8y_api/src/json_c8y.rs | 282 | ||||
-rw-r--r-- | crates/core/c8y_api/src/lib.rs | 12 |
5 files changed, 754 insertions, 0 deletions
diff --git a/crates/core/c8y_api/Cargo.toml b/crates/core/c8y_api/Cargo.toml new file mode 100644 index 00000000..1e3cb0f5 --- /dev/null +++ b/crates/core/c8y_api/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "c8y_api" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +agent_interface = { path = "../agent_interface"} +async-trait = "0.1" +batcher = { path = "../../common/batcher" } +c8y_smartrest = { path = "../c8y_smartrest" } +c8y_translator = { path = "../c8y_translator" } +chrono = "0.4" +clock = { path = "../../common/clock" } +csv = "1.1" +download = { path = "../../common/download" } +flockfile = { path = "../../common/flockfile" } +futures = "0.3" +mockall = "0.10" +mqtt_channel = { path = "../../common/mqtt_channel" } +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +structopt = "0.3" +tedge_config = { path = "../../common/tedge_config" } +tedge_users = { path = "../../common/tedge_users" } +tedge_utils = { path = "../../common/tedge_utils", features = ["logging"] } +thin_edge_json = { path = "../thin_edge_json" } +thiserror = "1.0" +time = { version = "0.3", features = ["formatting"] } +tokio = { version = "1.8", features = ["rt", "sync", "time"] } +toml = "0.5" +tracing = { version = "0.1", features = ["attributes", "log"] } + +[dev-dependencies] +tempfile = "3.3" +test-case = "1.2" diff --git a/crates/core/c8y_api/src/error.rs b/crates/core/c8y_api/src/error.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/core/c8y_api/src/error.rs @@ -0,0 +1 @@ + diff --git a/crates/core/c8y_api/src/http_proxy.rs b/crates/core/c8y_api/src/http_proxy.rs new file mode 100644 index 00000000..2a56946a --- /dev/null +++ b/crates/core/c8y_api/src/http_proxy.rs @@ -0,0 +1,421 @@ +use crate::json_c8y::{ + C8yCreateEvent, C8yManagedObject, C8yUpdateSoftwareListResponse, InternalIdResponse, +}; +use async_trait::async_trait; +use c8y_smartrest::{ + error::SMCumulocityMapperError, smartrest_deserializer::SmartRestJwtResponse, topic::C8yTopic, +}; +use mqtt_channel::{Connection, PubChannel, StreamExt, Topic, TopicFilter}; +use reqwest::Url; +use std::time::Duration; +use tedge_config::{ + get_tedge_config, C8yUrlSetting, ConfigSettingAccessor, ConfigSettingAccessorStringExt, + DeviceIdSetting, MqttPortSetting, TEdgeConfig, +}; +use time::{format_description, OffsetDateTime}; + +use serde::{Deserialize, Serialize}; +use tracing::{error, info, instrument}; + +const RETRY_TIMEOUT_SECS: u64 = 60; + +/// creates an mqtt client with a given `session_name` +pub async fn create_mqtt_client( + session_name: &str, +) -> Result<mqtt_channel::Connection, SMCumulocityMapperError> { + let tedge_config = get_tedge_config()?; + let mqtt_port = tedge_config.query(MqttPortSetting)?.into(); + let mqtt_config = mqtt_channel::Config::default() + .with_port(mqtt_port) + .with_session_name(session_name) + .with_subscriptions(mqtt_channel::TopicFilter::new_unchecked( + C8yTopic::SmartRestResponse.as_str(), + )); + + let mqtt_client = mqtt_channel::Connection::new(&mqtt_config).await?; + Ok(mqtt_client) +} + +/// creates an http client with a given `session_name` +pub async fn create_http_client( + session_name: &str, +) -> Result<JwtAuthHttpProxy, SMCumulocityMapperError> { + let config = get_tedge_config()?; + let mut http_proxy = JwtAuthHttpProxy::try_new(&config, &session_name).await?; + let () = http_proxy.init().await?; + Ok(http_proxy) +} + +/// An HttpProxy handles http requests to C8y on behalf of the device. +#[async_trait] +pub trait C8YHttpProxy { + async fn init(&mut self) -> Result<(), SMCumulocityMapperError>; + + fn url_is_in_my_tenant_domain(&self, url: &str) -> bool; + + async fn get_jwt_token(&mut self) -> Result<SmartRestJwtResponse, SMCumulocityMapperError>; + + async fn send_software_list_http( + &mut self, + c8y_software_list: &C8yUpdateSoftwareListResponse, + ) -> Result<(), SMCumulocityMapperError>; + + async fn upload_log_binary( + &mut self, + log_content: &str, + ) -> Result<String, SMCumulocityMapperError>; +} + +/// Define a C8y endpoint +pub struct C8yEndPoint { + c8y_host: String, + device_id: String, + c8y_internal_id: String, +} + +impl C8yEndPoint { + #[cfg(test)] + fn new(c8y_host: &str, device_id: &str, c8y_internal_id: &str) -> C8yEndPoint { + C8yEndPoint { + c8y_host: c8y_host.into(), + device_id: device_id.into(), + c8y_internal_id: c8y_internal_id.into(), + } + } + + fn get_url_for_sw_list(&self) -> String { + let mut url_update_swlist = String::new(); + url_update_swlist.push_str("https://"); + url_update_swlist.push_str(&self.c8y_host); + url_update_swlist.push_str("/inventory/managedObjects/"); + url_update_swlist.push_str(&self.c8y_internal_id); + + url_update_swlist + } + + fn get_url_for_get_id(&self) -> String { + let mut url_get_id = String::new(); + url_get_id.push_str("https://"); + url_get_id.push_str(&self.c8y_host); + url_get_id.push_str("/identity/externalIds/c8y_Serial/"); + url_get_id.push_str(&self.device_id); + + url_get_id + } + + fn get_url_for_create_event(&self) -> String { + let mut url_create_event = String::new(); + url_create_event.push_str("https://"); + url_create_event.push_str(&self.c8y_host); + url_create_event.push_str("/event/events/"); + + url_create_event + } + + fn get_url_for_event_binary_upload(&self, event_id: &str) -> String { + let mut url_event_binary = self.get_url_for_create_event(); + url_event_binary.push_str(event_id); + url_event_binary.push_str("/binaries"); + + url_event_binary + } + + fn url_is_in_my_tenant_domain(&self, url: &str) -> bool { + // c8y URL may contain either `Tenant Name` or Tenant Id` so they can be one of following options: + // * <tenant_name>.<domain> eg: sample.c8y.io + // * <tenant_id>.<domain> eg: t12345.c8y.io + // These URLs may be both equivalent and point to the same tenant. + // We are going to remove that and only check if the domain is the same. + let tenant_uri = &self.c8y_host; + let url_host = match Url::parse(url) { + Ok(url) => match url.host() { + Some(host) => host.to_string(), + None => return false, + }, + Err(_err) => { + return false; + } + }; + + let url_domain = url_host.splitn(2, '.').collect::<Vec<&str>>(); + let tenant_domain = tenant_uri.splitn(2, '.').collect::<Vec<&str>>(); + if url_domain.get(1) == tenant_domain.get(1) { + return true; + } + false + } +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// used to retrieve the id of a log event +pub struct SmartRestLogEvent { + pub id: String, +} + +/// An HttpProxy that uses MQTT to retrieve JWT tokens and authenticate the device +/// +/// - Keep the connection info to c8y and the internal Id of the device +/// - Handle JWT requests +pub struct JwtAuthHttpProxy { + mqtt_con: mqtt_channel::Connection, + http_con: reqwest::Client, + end_point: C8yEndPoint, +} + +impl JwtAuthHttpProxy { + pub fn new( + mqtt_con: mqtt_channel::Connection, + http_con: reqwest::Client, + c8y_host: &str, + device_id: &str, + ) -> JwtAuthHttpProxy { + JwtAuthHttpProxy { + mqtt_con, + http_con, + end_point: C8yEndPoint { + c8y_host: c8y_host.into(), + device_id: device_id.into(), + c8y_internal_id: "".into(), + }, + } + } + + pub async fn try_new( + tedge_config: &TEdgeConfig, + session_name: &str, + ) -> Result<JwtAuthHttpProxy, SMCumulocityMapperError> { + let c8y_host = tedge_config.query_string(C8yUrlSetting)?; + let device_id = tedge_config.query_string(DeviceIdSetting)?; + let http_con = reqwest::ClientBuilder::new().build()?; + + let mqtt_port = tedge_config.query(MqttPortSetting)?.into(); + let topic = TopicFilter::new("c8y/s/dat")?; + let mqtt_config = mqtt_channel::Config::default() + .with_port(mqtt_port) + .with_clean_session(true) + .with_session_name(session_name) + .with_subscriptions(topic); + let mut mqtt_con = Connection::new(&mqtt_config).await?; + + // Ignore errors on this connection + let () = mqtt_con.errors.close(); + + Ok(JwtAuthHttpProxy::new( + mqtt_con, http_con, &c8y_host, &device_id, + )) + } + + async fn try_get_and_set_internal_id(&mut self) -> Result<(), SMCumulocityMapperError> { + let token = self.get_jwt_token().await?; + let url_get_id = self.end_point.get_url_for_get_id(); + + self.end_point.c8y_internal_id = self + .try_get_internal_id(&url_get_id, &token.token()) + .await?; + + Ok(()) + } + + async fn try_get_internal_id( + &self, + url_get_id: &str, + token: &str, + ) -> Result<String, SMCumulocityMapperError> { + let internal_id = self + .http_con + .get(url_get_id) + .bearer_auth(token) + .send() + .await?; + let internal_id_response = internal_id.json::<InternalIdResponse>().await?; + + let internal_id = internal_id_response.id(); + Ok(internal_id) + } + + /// Make a POST request to /event/events and return the event id from response body. + /// The event id is used to upload the binary. + fn create_log_event(&self) -> C8yCreateEvent { + let local = OffsetDateTime::now_utc(); + + let c8y_managed_object = C8yManagedObject { + id: self.end_point.c8y_internal_id.clone(), + }; + + C8yCreateEvent::new( + c8y_managed_object, + "c8y_Logfile", + &local + .format(&format_description::well_known::Rfc3339) + .unwrap(), + "software-management", + ) + } + + async fn get_event_id( + &mut self, + c8y_event: C8yCreateEvent, + ) -> Result<String, SMCumulocityMapperError> { + let token = self.get_jwt_token().await?; + let create_event_url = self.end_point.get_url_for_create_event(); + + let request = self + .http_con + .post(create_event_url) + .json(&c8y_event) + .bearer_auth(token.token()) + .header("Accept", "application/json") + .timeout(Duration::from_millis(10000)) + .build()?; + + let response = self.http_con.execute(request).await?; + dbg!(&response); + let event_response_body = response.json::<SmartRestLogEvent>().await?; + + Ok(event_response_body.id) + } +} + +#[async_trait] +impl C8YHttpProxy for JwtAuthHttpProxy { + fn url_is_in_my_tenant_domain(&self, url: &str) -> bool { + self.end_point.url_is_in_my_tenant_domain(url) + } + + #[instrument(skip(self), name = "init")] + async fn init(&mut self) -> Result<(), SMCumulocityMapperError> { + info!("Initialisation"); + while self.end_point.c8y_internal_id.is_empty() { + if let Err(error) = self.try_get_and_set_internal_id().await { + error!( + "An error ocurred while retrieving internal Id, operation will retry in {} seconds and mapper will reinitialise.\n Error: {:?}", + RETRY_TIMEOUT_SECS, error + ); + + tokio::time::sleep(Duration::from_secs(RETRY_TIMEOUT_SECS)).await; + continue; + }; + } + info!("Initialisation done."); + Ok(()) + } + + async fn get_jwt_token(&mut self) -> Result<SmartRestJwtResponse, SMCumulocityMapperError> { + let () = self + .mqtt_con + .published + .publish(mqtt_channel::Message::new( + &Topic::new_unchecked("c8y/s/uat"), + "".to_string(), + )) + .await?; + let token_smartrest = match tokio::time::timeout( + Duration::from_secs(10), + self.mqtt_con.received.next(), + ) + .await + { + Ok(Some(msg)) => msg.payload_str()?.to_string(), + Ok(None) => return Err(SMCumulocityMapperError::InvalidMqttMessage), + Err(_elapsed) => return Err(SMCumulocityMapperError::RequestTimeout), + }; + + Ok(SmartRestJwtResponse::try_new(&token_smartrest)?) + } + + async fn send_software_list_http( + &mut self, + c8y_software_list: &C8yUpdateSoftwareListResponse, + ) -> Result<(), SMCumulocityMapperError> { + let url = self.end_point.get_url_for_sw_list(); + let token = self.get_jwt_token().await?; + + let request = self + .http_con + .put(url) + .json(c8y_software_list) + .bearer_auth(&token.token()) + .timeout(Duration::from_millis(10000)) + .build()?; + + let _response = self.http_con.execute(request).await?; + + Ok(()) + } + + async fn upload_log_binary( + &mut self, + log_content: &str, + ) -> Result<String, SMCumulocityMapperError> { + let token = self.get_jwt_token().await?; + + let log_event = self.create_log_event(); + let event_response_id = self.get_event_id(log_event).await?; + let binary_upload_event_url = self + .end_point + .get_url_for_event_binary_upload(&event_response_id); + + let request = self + .http_con + .post(&binary_upload_event_url) + .header("Accept", "application/json") + .header("Content-Type", "text/plain") + .body(log_content.to_string()) + .bearer_auth(token.token()) + .timeout(Duration::from_millis(10000)) + .build()?; + + let _response = self.http_con.execute(request).await?; + Ok(binary_upload_event_url) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_case::test_case; + + #[test] + fn get_url_for_get_id_returns_correct_address() { + let c8y = C8yEndPoint::new("test_host", "test_device", "internal-id"); + let res = c8y.get_url_for_get_id(); + + assert_eq!( + res, + "https://test_host/identity/externalIds/c8y_Serial/test_device" + ); + } + + #[test] + fn get_url_for_sw_list_returns_correct_address() { + let c8y = C8yEndPoint::new("test_host", "test_device", "12345"); + let res = c8y.get_url_for_sw_list(); + + assert_eq!(res, "https://test_host/inventory/managedObjects/12345"); + } + + #[test_case("http://aaa.test.com")] + #[test_case("https://aaa.test.com")] + #[test_case("ftp://aaa.test.com")] + #[test_case("mqtt://aaa.test.com")] + #[test_case("https://t1124124.test.com")] + #[test_case("https://t1124124.test.com:12345")] + #[test_case("https://t1124124.test.com/path")] + #[test_case("https://t1124124.test.com/path/to/file.test")] + #[test_case("https://t1124124.test.com/path/to/file")] + fn url_is_my_tenant_correct_urls(url: &str) { + let c8y = C8yEndPoint::new("test.test.com", "test_device", "internal-id"); + assert!(c8y.url_is_in_my_tenant_domain(url)); + } + + #[test_case("test.com")] + #[test_case("http://test.co")] + #[test_case("http://test.co.te")] + #[test_case("http://test.com:123456")] + #[test_case("http://test.com::12345")] + fn url_is_my_tenant_incorrect_urls(url: &str) { + let c8y = C8yEndPoint::new("test.test.com", "test_device", "internal-id"); + assert!(!c8y.url_is_in_my_tenant_domain(url)); + } +} diff --git a/crates/core/c8y_api/src/json_c8y.rs b/crates/core/c8y_api/src/json_c8y.rs new file mode 100644 index 00000000..8566a84c --- /dev/null +++ b/crates/core/c8y_api/src/json_c8y.rs @@ -0,0 +1,282 @@ +use agent_interface::{ + Jsonify, SoftwareListResponse, SoftwareModule, SoftwareType, SoftwareVersion, +}; + +use download::DownloadInfo; +use serde::{Deserialize, Serialize}; + +const EMPTY_STRING: &str = ""; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct C8yCreateEvent { + source: C8yManagedObject, + #[serde(rename = "type")] + event_type: String, + time: String, + text: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct C8yManagedObject { + pub id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InternalIdResponse { + managed_object: C8yManagedObject, + external_id: String, +} + +impl InternalIdResponse { + pub fn id(&self) -> String { + self.managed_object.id.clone() + } +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub struct C8ySoftwareModuleItem { + pub name: String, + pub version: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(flatten)] + pub url: Option<DownloadInfo>, +} + +impl<'a> Jsonify<'a> for C8ySoftwareModuleItem {} + +impl From<SoftwareModule> for C8ySoftwareModuleItem { + fn from(module: SoftwareModule) -> Self { + let url = if module.url.is_none() { + Some(EMPTY_STRING.into()) + } else { + module.url + }; + + Self { + name: module.name, + version: Option::from(combine_version_and_type( + &module.version, + &module.module_type, + )), + url, + } + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct C8yUpdateSoftwareListResponse { + #[serde(rename = "c8y_SoftwareList")] + c8y_software_list: Option<Vec<C8ySoftwareModuleItem>>, +} + +impl<'a> Jsonify<'a> for C8yUpdateSoftwareListResponse {} + +impl From<&SoftwareListResponse> for C8yUpdateSoftwareListResponse { + fn from(list: &SoftwareListResponse) -> Self { + let mut new_list: Vec<C8ySoftwareModuleItem> = Vec::new(); + list.modules().into_iter().for_each(|software_module| { + let c8y_software_module: C8ySoftwareModuleItem = software_module.into(); + new_list.push(c8y_software_module); + }); + + Self { + c8y_software_list: Some(new_list), + } + } +} + +impl C8yCreateEvent { + pub fn new(source: C8yManagedObject, event_type: &str, time: &str, text: &str) -> Self { + Self { + source, + event_type: event_type.into(), + time: time.into(), + text: text.into(), + } + } +} + +impl<'a> Jsonify<'a> for C8yCreateEvent {} + +fn combine_version_and_type( + version: &Option<SoftwareVersion>, + module_type: &Option<SoftwareType>, +) -> String { + match module_type { + Some(m) => { + if m.is_empty() { + match version { + Some(v) => v.into(), + None => EMPTY_STRING.into(), + } + } else { + match version { + Some(v) => format!("{}::{}", v, m), + None => format!("::{}", m), + } + } + } + None => match version { + Some(v) => { + if v.contains("::") { + format!("{}::", v) + } else { + v.into() + } + } + None => EMPTY_STRING.into(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_software_module_to_c8y_software_module_item() { + let software_module = SoftwareModule { + module_type: Some("a".into()), + name: "b".into(), + version: Some("c".into()), + url: Some("".into()), + file_path: None, + }; + + let expected_c8y_item = C8ySoftwareModuleItem { + name: "b".into(), + version: Some("c::a".into()), + url: Some("".into()), + }; + + let converted: C8ySoftwareModuleItem = software_module.into(); + + assert_eq!(converted, expected_c8y_item); + } + + #[test] + fn from_thin_edge_json_to_c8y_set_software_list() { + let input_json = r#"{ + "id":"1", + "status":"successful", + "currentSoftwareList":[ + {"type":"debian", "modules":[ + {"name":"a"}, + {"name":"b","version":"1.0"}, + {"name":"c","url":"https://foobar.io/c.deb"}, + {"name":"d","version":"beta","url":"https://foobar.io/d.deb"} + ]}, + {"type":"apama","modules":[ + {"name":"m","url":"https://foobar.io/m.epl"} + ]} + ]}"#; + + let json_obj = &SoftwareListResponse::from_json(input_json).unwrap(); + + let c8y_software_list: C8yUpdateSoftwareListResponse = json_obj.into(); + + let expected_struct = C8yUpdateSoftwareListResponse { + c8y_software_list: Some(vec![ + C8ySoftwareModuleItem { + name: "a".into(), + version: Some("::debian".into()), + url: Some("".into()), + }, + C8ySoftwareModuleItem { + name: "b".into(), + version: Some("1.0::debian".into()), + url: Some("".into()), + }, + C8ySoftwareModuleItem { + name: "c".into(), + version: Some("::debian".into()), + url: Some("https://foobar.io/c.deb".into()), + }, + C8ySoftwareModuleItem { + name: "d".into(), + version: Some("beta::debian".into()), + url: Some("https://foobar.io/d.deb".into()), + }, + C8ySoftwareModuleItem { + name: "m".into(), + version: Some("::apama".into()), + url: Some("https://foobar.io/m.epl".into()), + }, + ]), + }; + + let expected_json = r#"{"c8y_SoftwareList":[{"name":"a","version":"::debian","url":""},{"name":"b","version":"1.0::debian","url":""},{"name":"c","version":"::debian","url":"https://foobar.io/c.deb"},{"name":"d","version":"beta::debian","url":"https://foobar.io/d.deb"},{"name":"m","version":"::apama","url":"https://foobar.io/m.epl"}]}"#; + + assert_eq!(c8y_software_list, expected_struct); + assert_eq!(c8y_software_list.to_json().unwrap(), expected_json); + } + + #[test] + fn empty_to_c8y_set_software_list() { + let input_json = r#"{ + "id":"1", + "status":"successful", + "currentSoftwareList":[] + }"#; + + let json_obj = &SoftwareListResponse::from_json(input_json).unwrap(); + let c8y_software_list: C8yUpdateSoftwareListResponse = json_obj.into(); + + let expected_struct = C8yUpdateSoftwareListResponse { + c8y_software_list: Some(vec![]), + }; + let expected_json = r#"{"c8y_SoftwareList":[]}"#; + + assert_eq!(c8y_software_list, expected_struct); + assert_eq!(c8y_software_list.to_json().unwrap(), expected_json); + } + + #[test] + fn get_id_from_c8y_response() { + let managed_object = C8yManagedObject { id: "12345".into() }; + let response = InternalIdResponse { + managed_object, + external_id: "test".into(), + }; + + assert_eq!(response.id(), "12345".to_string()); + } + + #[test] + fn verify_combine_version_and_type() { + let some_version: Option<SoftwareVersion> = Some("1.0".to_string()); + let some_version_with_colon: Option<SoftwareVersion> = Some("1.0.0::1".to_string()); + let none_version: Option<SoftwareVersion> = None; + let some_module_type: Option<SoftwareType> = Some("debian".to_string()); + let none_module_type: Option<SoftwareType> = None; + + assert_eq!( + combine_version_and_type(&some_version, &some_module_type), + "1.0::debian" + ); + assert_eq!( + combine_version_and_type(&some_version, &none_module_type), + "1.0" + ); + assert_eq!( + combine_version_and_type(&some_version_with_colon, &some_module_type), + "1.0.0::1::debian" + ); + assert_eq!( + combine_version_and_type(&some_version_with_colon, &none_module_type), + "1.0.0::1::" + ); + assert_eq!( + combine_version_and_type(&none_version, &some_module_type), + "::debian" + ); + assert_eq!( + combine_version_and_type(&none_version, &none_module_type), + EMPTY_STRING + ); + } +} diff --git a/crates/core/c8y_api/src/lib.rs b/crates/core/c8y_api/src/lib.rs new file mode 100644 index 00000000..2a0286e0 --- /dev/null +++ b/crates/core/c8y_api/src/lib.rs @@ -0,0 +1,12 @@ +pub mod error; +pub mod http_proxy; +pub mod json_c8y; + +#[cfg(test)] +mod tests { + #[test] + fn it_works() { + let result = 2 + 2; + assert_eq!(result, 4); + } +} |