Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: New tedge http command #3357

Merged
merged 10 commits into from
Feb 10, 2025
287 changes: 287 additions & 0 deletions crates/core/tedge/src/cli/http/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
use crate::cli::http::command::HttpAction;
use crate::cli::http::command::HttpCommand;
use crate::command::BuildCommand;
use crate::command::BuildContext;
use crate::command::Command;
use crate::ConfigError;
use anyhow::anyhow;
use anyhow::Error;
use camino::Utf8PathBuf;
use certificate::CloudRootCerts;
use clap::Args;
use reqwest::blocking;
use reqwest::Identity;
use std::fs::File;
use tedge_config::OptionalConfig;
use tedge_config::ProfileName;

#[derive(clap::Subcommand, Debug)]
pub enum TEdgeHttpCli {
/// POST content to thin-edge local HTTP servers
///
/// Examples:
/// # Create a new Cumulocity Managed Object via the proxy service
albinsuresh marked this conversation as resolved.
Show resolved Hide resolved
/// tedge http post /c8y/inventory/managedObjects '{"name":"test"}' --accept-type application/json
///
/// # Create a new child device
/// tedge http post /tedge/entity-store/v1/entities '{
/// "@topic-id": "device/a//",
/// "@type": "child-device",
/// "@parent": "device/main//"
/// }'
#[clap(verbatim_doc_comment)]
Post {
/// Target URI
uri: String,

/// Content to send
#[command(flatten)]
content: Content,

/// MIME type of the content
#[clap(long)]
#[arg(value_parser = parse_mime_type)]
content_type: Option<String>,

/// MIME type of the expected content
#[clap(long)]
#[arg(value_parser = parse_mime_type)]
accept_type: Option<String>,

/// Optional c8y cloud profile
#[clap(long)]
profile: Option<ProfileName>,
},

/// PUT content to thin-edge local HTTP servers
///
/// Examples:
/// # Upload file to the file transfer service
/// tedge http put /tedge/file-transfer/target.txt --file source.txt
///
/// # Update a Cumulocity Managed Object. Note: Assuming tedge is the owner of the managed object
/// tedge http put /c8y/inventory/managedObjects/2343978440 '{"name":"item A"}' --accept-type application/json
#[clap(verbatim_doc_comment)]
Put {
/// Target URI
uri: String,

/// Content to send
#[command(flatten)]
content: Content,

/// MIME type of the content
#[clap(long)]
#[arg(value_parser = parse_mime_type)]
content_type: Option<String>,

/// MIME type of the expected content
#[clap(long)]
#[arg(value_parser = parse_mime_type)]
accept_type: Option<String>,

/// Optional c8y cloud profile
#[clap(long)]
profile: Option<ProfileName>,
},

/// GET content from thin-edge local HTTP servers
///
/// Examples:
/// # Download file from the file transfer service
/// tedge http get /tedge/file-transfer/target.txt
///
/// # Download file from Cumulocity's binary api
/// tedge http get /c8y/inventory/binaries/104332 > my_file.bin
#[clap(verbatim_doc_comment)]
Get {
/// Source URI
uri: String,

/// MIME type of the expected content
#[clap(long)]
#[arg(value_parser = parse_mime_type)]
accept_type: Option<String>,

/// Optional c8y cloud profile
#[clap(long)]
profile: Option<ProfileName>,
},

/// DELETE resource from thin-edge local HTTP servers
///
/// Examples:
/// # Delete a file from the file transfer service
/// tedge http delete /tedge/file-transfer/target.txt
///
/// # Delete a Cumulocity managed object. Note: Assuming tedge is the owner of the managed object
/// tedge http delete /c8y/inventory/managedObjects/2343978440
#[clap(verbatim_doc_comment)]
Delete {
/// Source URI
uri: String,

/// Optional c8y cloud profile
#[clap(long)]
profile: Option<ProfileName>,
},
}

#[derive(Args, Clone, Debug)]
#[group(required = true, multiple = false)]
pub struct Content {
/// Content to send
#[arg(name = "content")]
arg2: Option<String>,

/// Content to send
#[arg(long)]
data: Option<String>,
reubenmiller marked this conversation as resolved.
Show resolved Hide resolved

/// File which content is sent
#[arg(long)]
file: Option<Utf8PathBuf>,
}

fn parse_mime_type(input: &str) -> Result<String, Error> {
Ok(input.parse::<mime_guess::mime::Mime>()?.to_string())
}

impl TryFrom<Content> for blocking::Body {
type Error = std::io::Error;

fn try_from(content: Content) -> Result<Self, Self::Error> {
let body: blocking::Body = if let Some(data) = content.arg2 {
data.into()
} else if let Some(data) = content.data {
data.into()
} else if let Some(file) = content.file {
File::open(file)?.into()
} else {
"".into()
};

Ok(body)
}
}

impl BuildCommand for TEdgeHttpCli {
fn build_command(self, context: BuildContext) -> Result<Box<dyn Command>, ConfigError> {
let config = context.load_config()?;
let uri = self.uri();

let (protocol, host, port) = if uri.starts_with("/c8y") {
let c8y_config = config.c8y.try_get(self.c8y_profile())?;
let client = &c8y_config.proxy.client;
let protocol = https_if_some(&c8y_config.proxy.cert_path);
(protocol, client.host.clone(), client.port)
} else if uri.starts_with("/tedge") {
let client = &config.http.client;
let protocol = https_if_some(&config.http.cert_path);
(protocol, client.host.clone(), client.port)
} else {
return Err(anyhow!("Not a local HTTP uri: {uri}").into());
};

let url = format!("{protocol}://{host}:{port}{uri}");
let identity = config.http.client.auth.identity()?;
let client = http_client(config.cloud_root_certs(), identity.as_ref())?;
let action = self.into();

Ok(HttpCommand {
client,
url,
action,
}
.into_boxed())
}
}

impl From<TEdgeHttpCli> for HttpAction {
fn from(value: TEdgeHttpCli) -> Self {
match value {
TEdgeHttpCli::Post {
content,
content_type,
accept_type,
..
} => HttpAction::Post {
content,
content_type,
accept_type,
},
TEdgeHttpCli::Put {
content,
content_type,
accept_type,
..
} => HttpAction::Put {
content,
content_type,
accept_type,
},
TEdgeHttpCli::Get { accept_type, .. } => HttpAction::Get { accept_type },
TEdgeHttpCli::Delete { .. } => HttpAction::Delete,
}
}
}

impl TEdgeHttpCli {
fn uri(&self) -> &str {
match self {
TEdgeHttpCli::Post { uri, .. }
| TEdgeHttpCli::Put { uri, .. }
| TEdgeHttpCli::Get { uri, .. }
| TEdgeHttpCli::Delete { uri, .. } => uri.as_ref(),
}
}

fn c8y_profile(&self) -> Option<&ProfileName> {
match self {
TEdgeHttpCli::Post { profile, .. }
| TEdgeHttpCli::Put { profile, .. }
| TEdgeHttpCli::Get { profile, .. }
| TEdgeHttpCli::Delete { profile, .. } => profile.as_ref(),
}
}
}

fn https_if_some<T>(cert_path: &OptionalConfig<T>) -> &'static str {
cert_path.or_none().map_or("http", |_| "https")
}

fn http_client(
root_certs: CloudRootCerts,
identity: Option<&Identity>,
) -> Result<blocking::Client, Error> {
let builder = root_certs.blocking_client_builder();
let builder = if let Some(identity) = identity {
builder.identity(identity.clone())
} else {
builder
};
Ok(builder.build()?)
}

impl Content {
pub fn length(&self) -> Option<usize> {
if let Some(content) = &self.arg2 {
Some(content.len())
} else if let Some(data) = &self.data {
Some(data.len())
} else if let Some(file) = &self.file {
Some(std::fs::metadata(file).ok()?.len().try_into().ok()?)
} else {
None
}
}

pub fn mime_type(&self) -> Option<String> {
let file = self.file.as_ref()?;
Some(
mime_guess::from_path(file)
.first_or_octet_stream()
Copy link
Contributor

@albinsuresh albinsuresh Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be better to default to None here rather than default to octet stream, so that we don't send the mime type header at all, rather than possibly sending the wrong one? This would even mess with the default logic at the HttpAction level, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"application/octet-stream" is general enough to be safe.

.to_string(),
)
}
}
Loading