Skip to content

Commit

Permalink
feat: CLI get command improvements (#331)
Browse files Browse the repository at this point in the history
This focuses on creating tests for the `iroh get` CLI command. The Iroh API is also improved along the way.

This required more work in the underlying API to make it more testable. What's tested here is everything *except* the actual IPFS behavior -- the focus is on the behavior of the CLI and API and its interactions with the filesystem.

 ## trycmd tests

In `iroh/tests/cmd` you can see quite a few `.trycmd` files. These describes the command-line interaction. Stuff behind `$ is a command, there's a special `? failed` indicator if the command is considered to have failed, and the output of the command is shown.

New are the `.out` and `.in` directories with the same names as the `.trycmd` files. These describe the filesystem before the command runs (may be missing), and the filesystem after the command has run. This way we can describe the effects of the `iroh get` command - directories and files are supposed to be created. We can also test failure scenarios where we refuse to overwrite a directory that already exists. #269 tracks various test cases.

## `get_stream`

The `get` high level CLI method has been removed from the mockable `Api` trait (read on to see where it went). Instead, a more low level but still useful API method `get_stream` has been added to the `Api` trait. This gets a stream of relative paths and `OutType`, describing the directories and files you can create. 

The big difference with what was there before is that it returns relative paths and doesn't calculate final destination paths -- that's up to the user of the API.

## No `async_trait` macro for `Api` trait

The interactions between the `Api` trait, `mockall` macro and `async_trait` were getting so hairy I couldn't figure out how to express things anymore once I wanted to add `get_stream`. For my sanity and also to learn better how this really works underneath, I've rewritten the `Api` trait to describe itself explicitly in terms of `(Local)BoxFuture` and `(Local)_BoxStream`. It's more verbose but functionally equivalent and I could express what I wanted. 

## `ApiExt` trait

The `ApiExt` trait is a trait that implements the high level `get` command. It puts everything together: it handles various error conditions, accesses the stream and then writes the stream to the filesystem. It's basically `iroh get`.

The `ApiExt` trait is solely intended to contain default trait methods, and is automatically available when the `Api` contract is fulfilled (if you `use` it).

Factored out `save_get_stream` from `getadd.rs` to be solely concerned with turning a stream into files and directories on the files system. That makes it possible to test its behavior in isolation.

## test fixture

The `get` test fixture now mocks `get_stream` and returns a fake stream made from a `Vec`. This defines the actual stuff that the CLI writes to disk.

## relative_path

Now depend on the [`relative-path` crate](https://crates.io/crates/relative-path) because what the stream returns are clearly relative paths, and we want to force the user to do something with them before being able to actually write stuff.
  • Loading branch information
faassen authored Oct 14, 2022
1 parent 7b1d32a commit b18b6c8
Show file tree
Hide file tree
Showing 36 changed files with 491 additions and 192 deletions.
3 changes: 3 additions & 0 deletions iroh-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ futures = "0.3.21"
async-stream = "0.3.3"
mockall = { version = "0.11.2", optional = true }
serde = { version = "1.0", features = ["derive"] }
relative-path = "1.7.2"

[dev-dependencies]
tempdir = "0.3.7"
123 changes: 78 additions & 45 deletions iroh-api/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,55 @@ use crate::config::{Config, CONFIG_FILE_NAME, ENV_PREFIX};
#[cfg(feature = "testing")]
use crate::p2p::MockP2p;
use crate::p2p::{ClientP2p, P2p};
use crate::{Cid, IpfsPath};
use anyhow::Result;
use async_trait::async_trait;
use cid::Cid;
use futures::future::{BoxFuture, LocalBoxFuture};
use futures::stream::LocalBoxStream;
use futures::FutureExt;
use futures::StreamExt;
use iroh_resolver::resolver::Path as IpfsPath;
use iroh_resolver::{resolver, unixfs_builder};
use iroh_resolver::unixfs_builder;
use iroh_rpc_client::Client;
use iroh_rpc_client::StatusTable;
use iroh_util::{iroh_config_path, make_config};
#[cfg(feature = "testing")]
use mockall::automock;
use relative_path::RelativePathBuf;
use tokio::io::AsyncRead;

pub struct Iroh {
client: Client,
}

pub enum OutType<T: resolver::ContentLoader> {
pub enum OutType {
Dir,
Reader(resolver::OutPrettyReader<T>),
Reader(Box<dyn AsyncRead + Unpin>),
}

// Note: `#[async_trait]` is deliberately not in use for this trait, because it
// became very hard to express what we wanted once streams were involved.
// Instead we spell things out explicitly without magic.

#[cfg_attr(feature= "testing", automock(type P = MockP2p;))]
#[async_trait(?Send)]
pub trait Api {
type P: P2p;

fn p2p(&self) -> Result<Self::P>;

async fn get<'a>(&self, ipfs_path: &IpfsPath, output: Option<&'a Path>) -> Result<()>;
async fn add(&self, path: &Path, recursive: bool, no_wrap: bool) -> Result<Cid>;
async fn check(&self) -> StatusTable;
async fn watch<'a>(&self) -> LocalBoxStream<'a, StatusTable>;
/// Produces a asynchronous stream of file descriptions
/// Each description is a tuple of a relative path, and either a `Directory` or a `Reader`
/// with the file contents.
fn get_stream(
&self,
ipfs_path: &IpfsPath,
) -> LocalBoxStream<'_, Result<(RelativePathBuf, OutType)>>;
fn add<'a>(
&'a self,
path: &'a Path,
recursive: bool,
no_wrap: bool,
) -> LocalBoxFuture<'_, Result<Cid>>;
fn check(&self) -> BoxFuture<'_, StatusTable>;
fn watch(&self) -> LocalBoxFuture<'static, LocalBoxStream<'static, StatusTable>>;
}

impl Iroh {
Expand Down Expand Up @@ -67,13 +83,8 @@ impl Iroh {
fn from_client(client: Client) -> Self {
Self { client }
}

pub(crate) fn get_client(&self) -> &Client {
&self.client
}
}

#[async_trait(?Send)]
impl Api for Iroh {
type P = ClientP2p;

Expand All @@ -82,45 +93,67 @@ impl Api for Iroh {
Ok(ClientP2p::new(p2p_client.clone()))
}

async fn get<'b>(&self, ipfs_path: &IpfsPath, output: Option<&'b Path>) -> Result<()> {
let blocks = self.get_stream(ipfs_path, output);
tokio::pin!(blocks);
while let Some(block) = blocks.next().await {
let (path, out) = block?;
match out {
OutType::Dir => {
tokio::fs::create_dir_all(path).await?;
fn get_stream(
&self,
ipfs_path: &IpfsPath,
) -> LocalBoxStream<'_, Result<(RelativePathBuf, OutType)>> {
tracing::debug!("get {:?}", ipfs_path);
let resolver = iroh_resolver::resolver::Resolver::new(self.client.clone());
let results = resolver.resolve_recursive_with_paths(ipfs_path.clone());
let sub_path = ipfs_path.to_relative_string();
async_stream::try_stream! {
tokio::pin!(results);
while let Some(res) = results.next().await {
let (relative_ipfs_path, out) = res?;
let relative_path = RelativePathBuf::from_path(&relative_ipfs_path.to_relative_string())?;
// TODO(faassen) this focusing in on sub-paths should really be handled in the resolver:
// * it can be tested there far more easily than here (where currently it isn't)
// * it makes sense to have an API "what does this resolve to" in the resolver
// * the resolver may have opportunities for optimization we don't have
if !relative_path.starts_with(&sub_path) {
continue;
}
OutType::Reader(mut reader) => {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut f = tokio::fs::File::create(path).await?;
tokio::io::copy(&mut reader, &mut f).await?;
let relative_path = relative_path.strip_prefix(&sub_path).expect("should be a prefix").to_owned();
if out.is_dir() {
yield (relative_path, OutType::Dir);
} else {
let reader = out.pretty(resolver.clone(), Default::default())?;
yield (relative_path, OutType::Reader(Box::new(reader)));
}
}
}
Ok(())
.boxed_local()
}

async fn add(&self, path: &Path, recursive: bool, no_wrap: bool) -> Result<Cid> {
let providing_client = iroh_resolver::unixfs_builder::StoreAndProvideClient {
client: Box::new(self.get_client()),
};
if path.is_dir() {
unixfs_builder::add_dir(Some(&providing_client), path, !no_wrap, recursive).await
} else if path.is_file() {
unixfs_builder::add_file(Some(&providing_client), path, !no_wrap).await
} else {
anyhow::bail!("can only add files or directories");
fn add<'a>(
&'a self,
path: &'a Path,
recursive: bool,
no_wrap: bool,
) -> LocalBoxFuture<'_, Result<Cid>> {
async move {
let providing_client = iroh_resolver::unixfs_builder::StoreAndProvideClient {
client: Box::new(&self.client),
};
if path.is_dir() {
unixfs_builder::add_dir(Some(&providing_client), path, !no_wrap, recursive).await
} else if path.is_file() {
unixfs_builder::add_file(Some(&providing_client), path, !no_wrap).await
} else {
anyhow::bail!("can only add files or directories");
}
}
.boxed_local()
}

async fn check(&self) -> StatusTable {
self.client.check().await
fn check(&self) -> BoxFuture<'_, StatusTable> {
async { self.client.check().await }.boxed()
}

async fn watch<'b>(&self) -> LocalBoxStream<'b, iroh_rpc_client::StatusTable> {
self.client.clone().watch().await.boxed()
fn watch(
&self,
) -> LocalBoxFuture<'static, LocalBoxStream<'static, iroh_rpc_client::StatusTable>> {
let client = self.client.clone();
async { client.watch().await.boxed_local() }.boxed_local()
}
}
124 changes: 124 additions & 0 deletions iroh-api/src/api_ext.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use std::path::{Path, PathBuf};

use crate::{Api, IpfsPath, OutType};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use futures::Stream;
use futures::StreamExt;
use relative_path::RelativePathBuf;

#[async_trait(?Send)]
pub trait ApiExt: Api {
/// High level get, equivalent of CLI `iroh get`
async fn get<'a>(
&self,
ipfs_path: &IpfsPath,
output_path: Option<&'a Path>,
) -> Result<PathBuf> {
if ipfs_path.cid().is_none() {
return Err(anyhow!("IPFS path does not refer to a CID"));
}
let root_path = get_root_path(ipfs_path, output_path);
if root_path.exists() {
return Err(anyhow!(
"output path {} already exists",
root_path.display()
));
}
let blocks = self.get_stream(ipfs_path);
save_get_stream(&root_path, blocks).await?;
Ok(root_path)
}
}

impl<T> ApiExt for T where T: Api {}

/// take a stream of blocks as from `get_stream` and write them to the filesystem
async fn save_get_stream(
root_path: &Path,
blocks: impl Stream<Item = Result<(RelativePathBuf, OutType)>>,
) -> Result<()> {
tokio::pin!(blocks);
while let Some(block) = blocks.next().await {
let (path, out) = block?;
let full_path = path.to_path(root_path);
match out {
OutType::Dir => {
tokio::fs::create_dir_all(full_path).await?;
}
OutType::Reader(mut reader) => {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent.to_path(root_path)).await?;
}
let mut f = tokio::fs::File::create(full_path).await?;
tokio::io::copy(&mut reader, &mut f).await?;
}
}
}
Ok(())
}

/// Given an cid and an optional output path, determine root path
fn get_root_path(ipfs_path: &IpfsPath, output_path: Option<&Path>) -> PathBuf {
match output_path {
Some(path) => path.to_path_buf(),
None => {
if ipfs_path.tail().is_empty() {
PathBuf::from(ipfs_path.cid().unwrap().to_string())
} else {
PathBuf::from(ipfs_path.tail().last().unwrap())
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
use tempdir::TempDir;

#[tokio::test]
async fn test_save_get_stream() {
let stream = Box::pin(futures::stream::iter(vec![
Ok((RelativePathBuf::from_path("a").unwrap(), OutType::Dir)),
Ok((
RelativePathBuf::from_path("b").unwrap(),
OutType::Reader(Box::new(std::io::Cursor::new("hello"))),
)),
]));
let tmp_dir = TempDir::new("test_save_get_stream").unwrap();
save_get_stream(tmp_dir.path(), stream).await.unwrap();
assert!(tmp_dir.path().join("a").is_dir());
assert_eq!(
std::fs::read_to_string(tmp_dir.path().join("b")).unwrap(),
"hello"
);
}

#[test]
fn test_get_root_path() {
let ipfs_path =
IpfsPath::from_str("/ipfs/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N").unwrap();
assert_eq!(
get_root_path(&ipfs_path, None),
PathBuf::from("QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N")
);
assert_eq!(
get_root_path(&ipfs_path, Some(Path::new("bar"))),
PathBuf::from("bar")
);
}

#[test]
fn test_get_root_path_with_tail() {
let ipfs_path =
IpfsPath::from_str("/ipfs/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N/tail")
.unwrap();
assert_eq!(get_root_path(&ipfs_path, None), PathBuf::from("tail"));
assert_eq!(
get_root_path(&ipfs_path, Some(Path::new("bar"))),
PathBuf::from("bar")
);
}
}
Loading

0 comments on commit b18b6c8

Please sign in to comment.