diff options
Diffstat (limited to 'adenosine-cli/src')
| -rw-r--r-- | adenosine-cli/src/bin/adenosine.rs (renamed from adenosine-cli/src/main.rs) | 149 | ||||
| -rw-r--r-- | adenosine-cli/src/identifiers.rs | 140 | ||||
| -rw-r--r-- | adenosine-cli/src/lib.rs | 3 | 
3 files changed, 255 insertions, 37 deletions
| diff --git a/adenosine-cli/src/main.rs b/adenosine-cli/src/bin/adenosine.rs index eea16bc..1d23c27 100644 --- a/adenosine-cli/src/main.rs +++ b/adenosine-cli/src/bin/adenosine.rs @@ -66,39 +66,37 @@ enum AccountCommand {      },      Logout,      Info, -    // TODO: CreateRevocationKey, +    // TODO: CreateRevocationKey or CreateDid  }  #[derive(StructOpt)]  enum RepoCommand {      Root { -        #[structopt(long)] -        did: String, +        did: Option<DidOrHost>,      },      Export { -        #[structopt(long)] -        did: String, +        did: Option<DidOrHost>,          #[structopt(long)]          from: Option<String>,      },      Import { -        // XXX: either path or stdin +        // TODO: could accept either path or stdin?          #[structopt(long)] -        did: String, +        did: Option<DidOrHost>,      },  }  #[derive(StructOpt)]  enum BskyCommand { -    Feed { name: Option<String> }, +    Feed { name: Option<DidOrHost> },      Notifications,      Post { text: String }, -    Repost { uri: String }, -    Like { uri: String }, +    Repost { uri: AtUri }, +    Like { uri: AtUri },      // TODO: Repost { uri: String, }, -    Follow { uri: String }, +    Follow { uri: DidOrHost },      // TODO: Unfollow { uri: String, }, -    /* +    /* TODO:      Follows {          name: String,      }, @@ -106,44 +104,47 @@ enum BskyCommand {          name: String,      },      */ -    Profile { name: String }, +    Profile { name: DidOrHost },      SearchUsers { query: String },  }  #[derive(StructOpt)]  enum Command {      Get { -        uri: String, +        uri: AtUri, + +        #[structopt(long)] +        cid: Option<String>,      },      Ls { -        uri: String, +        uri: AtUri,      },      Create {          collection: String, -        body: String, +        fields: String,      },      Update { -        uri: String, -        params: String, +        uri: AtUri, +        fields: String,      },      Delete { -        uri: String, +        uri: AtUri,      },      Describe { -        name: String, +        name: Option<DidOrHost>,      },      Resolve { -        name: String, +        name: DidOrHost,      },      Xrpc {          method: XrpcMethod,          nsid: String, -        params: Option<String>, +        fields: Option<String>,      },      /// Sub-commands for managing account @@ -248,34 +249,90 @@ fn run(opt: Opt) -> Result<()> {              None          }          Command::Describe { name } => { -            params.insert("user".to_string(), name); +            let name = name +                .map(|v| v.to_string()) +                .or(jwt_did) +                .ok_or(anyhow!("expected a name or auth token"))?; +            params.insert("user".to_string(), name.to_string());              xrpc_client.get("com.atproto.repoDescribe", Some(params))?          }          Command::Resolve { name } => {              let mut params: HashMap<String, String> = HashMap::new(); -            params.insert("name".to_string(), name); +            params.insert("name".to_string(), name.to_string());              xrpc_client.get("com.atproto.resolveName", Some(params))?          } -        Command::Get { uri } => { -            println!("GET: {}", uri); -            None +        Command::Get { uri, cid } => { +            params.insert("did".to_string(), uri.repository.to_string()); +            params.insert( +                "collection".to_string(), +                uri.collection.ok_or(anyhow!("collection required"))?, +            ); +            params.insert( +                "rkey".to_string(), +                uri.record.ok_or(anyhow!("record key required"))?, +            ); +            if let Some(c) = cid { +                params.insert("cid".to_string(), c); +            } +            xrpc_client.post("com.atproto.repoGetRecord", Some(params), json!({}))?          }          Command::Ls { uri } => { -            unimplemented!() +            // TODO: option to print fully-qualified path? +            if !uri.collection.is_some() { +                // if a repository, but no collection, list the collections +                params.insert("user".to_string(), uri.repository.to_string()); +                let describe = xrpc_client +                    .get("com.atproto.repoDescribe", Some(params))? +                    .ok_or(anyhow!("expected a repoDescribe response"))?; +                for c in describe["collections"] +                    .as_array() +                    .ok_or(anyhow!("expected collection list"))? +                { +                    println!( +                        "{}", +                        c.as_str() +                            .ok_or(anyhow!("expected collection as a JSON string"))? +                    ); +                } +            } else if uri.collection.is_some() && !uri.record.is_some() { +                // if a collection, but no record, list the records (with extracted timestamps) +            } else { +                return Err(anyhow!("got too much of a URI to 'ls'")); +            } +            None          } -        Command::Create { collection, body } => { +        Command::Create { collection, fields } => { +            params.insert("collection".to_string(), collection);              unimplemented!()          } -        Command::Update { uri, params } => { +        Command::Update { uri, fields } => { +            params.insert("did".to_string(), uri.repository.to_string()); +            params.insert( +                "collection".to_string(), +                uri.collection.ok_or(anyhow!("collection required"))?, +            ); +            params.insert( +                "rkey".to_string(), +                uri.record.ok_or(anyhow!("record key required"))?, +            );              unimplemented!()          }          Command::Delete { uri } => { -            unimplemented!() +            params.insert("did".to_string(), uri.repository.to_string()); +            params.insert( +                "collection".to_string(), +                uri.collection.ok_or(anyhow!("collection required"))?, +            ); +            params.insert( +                "rkey".to_string(), +                uri.record.ok_or(anyhow!("record key required"))?, +            ); +            xrpc_client.post("com.atproto.repoDeleteRecord", Some(params), json!({}))?          }          Command::Xrpc {              method,              nsid, -            params, +            fields,          } => {              let body: Value = ().into();              match method { @@ -322,12 +379,22 @@ fn run(opt: Opt) -> Result<()> {          Command::Repo {              cmd: RepoCommand::Root { did },          } => { +            let did = match did { +                Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")), +                Some(v) => v.to_string(), +                None => jwt_did.ok_or(anyhow!("expected a DID"))?, +            };              params.insert("did".to_string(), did);              xrpc_client.get("com.atproto.syncGetRoot", Some(params))?          }          Command::Repo {              cmd: RepoCommand::Export { did, from },          } => { +            let did = match did { +                Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")), +                Some(v) => v.to_string(), +                None => jwt_did.ok_or(anyhow!("expected a DID"))?, +            };              params.insert("did".to_string(), did);              if let Some(from) = from {                  params.insert("from".to_string(), from); @@ -342,6 +409,11 @@ fn run(opt: Opt) -> Result<()> {          Command::Repo {              cmd: RepoCommand::Import { did },          } => { +            let did = match did { +                Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")), +                Some(v) => v.to_string(), +                None => jwt_did.ok_or(anyhow!("expected a DID"))?, +            };              params.insert("did".to_string(), did);              xrpc_client.post_cbor_from_reader(                  "com.atproto.syncUpdateRepo", @@ -353,7 +425,7 @@ fn run(opt: Opt) -> Result<()> {              cmd: BskyCommand::Feed { name },          } => {              if let Some(name) = name { -                params.insert("author".to_string(), name); +                params.insert("author".to_string(), name.to_string());                  xrpc_client.get("app.bsky.getAuthorFeed", Some(params))?              } else {                  xrpc_client.get("app.bsky.getHomeFeed", None)? @@ -390,7 +462,8 @@ fn run(opt: Opt) -> Result<()> {                  "com.atproto.repoCreateRecord",                  Some(params),                  json!({ -                    "subject": uri, +                    "subject": uri.to_string(), +                    // TODO: "createdAt": now_timestamp(),                  }),              )?          } @@ -406,7 +479,8 @@ fn run(opt: Opt) -> Result<()> {                  "com.atproto.repoCreateRecord",                  Some(params),                  json!({ -                    "subject": uri, +                    "subject": uri.to_string(), +                    // TODO: "createdAt": now_timestamp(),                  }),              )?          } @@ -422,14 +496,15 @@ fn run(opt: Opt) -> Result<()> {                  "com.atproto.repoCreateRecord",                  Some(params),                  json!({ -                    "subject": uri, +                    "subject": uri.to_string(), +                    // TODO: "createdAt": now_timestamp(),                  }),              )?          }          Command::Bsky {              cmd: BskyCommand::Profile { name },          } => { -            params.insert("name".to_string(), name); +            params.insert("name".to_string(), name.to_string());              xrpc_client.get("app.bsky.getProfile", Some(params))?          }          Command::Bsky { diff --git a/adenosine-cli/src/identifiers.rs b/adenosine-cli/src/identifiers.rs new file mode 100644 index 0000000..7129ba5 --- /dev/null +++ b/adenosine-cli/src/identifiers.rs @@ -0,0 +1,140 @@ +use anyhow::{anyhow, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum DidOrHost { +    Did(String, String), +    Host(String), +} + +impl FromStr for DidOrHost { +    type Err = anyhow::Error; + +    /// DID syntax is specified in: <https://w3c.github.io/did-core/#did-syntax> +    /// +    /// Lazy partial hostname regex, isn't very correct. +    fn from_str(s: &str) -> Result<Self, Self::Err> { +        lazy_static! { +            static ref DID_RE: Regex = +                Regex::new(r"^did:([a-z]{1,64}):([a-zA-Z0-9\-.]{1,1024})$").unwrap(); +        } +        lazy_static! { +            static ref HOSTNAME_RE: Regex = +                Regex::new(r"^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$").unwrap(); +        } +        if let Some(caps) = DID_RE.captures(s) { +            Ok(Self::Did(caps[1].to_string(), caps[2].to_string())) +        } else if HOSTNAME_RE.is_match(s) { +            Ok(Self::Host(s.to_string())) +        } else { +            Err(anyhow!("does not match as a DID or hostname: {}", s)) +        } +    } +} + +impl fmt::Display for DidOrHost { +    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +        match self { +            Self::Host(v) => write!(f, "{}", v), +            Self::Did(m, v) => write!(f, "did:{}:{}", m, v), +        } +    } +} + +#[test] +fn test_didorhost() { +    assert_eq!( +        DidOrHost::from_str("hyde.test").unwrap(), +        DidOrHost::Host("hyde.test".to_string()) +    ); +    assert_eq!( +        DidOrHost::from_str("did:method:blah").unwrap(), +        DidOrHost::Did("method".to_string(), "blah".to_string()) +    ); + +    assert!(DidOrHost::from_str("barestring").is_err()); +    assert!(DidOrHost::from_str("did:partial:").is_err()); +    assert!(DidOrHost::from_str("").is_err()); +    assert!(DidOrHost::from_str(" ").is_err()); +    assert!(DidOrHost::from_str("1234").is_err()); + +    assert!(DidOrHost::from_str("mutli.part.domain").is_ok()); +    assert!(DidOrHost::from_str("did:is:weird").is_ok()); +    assert!(DidOrHost::from_str("did:plc:bv6ggog3tya2z3vxsub7hnal").is_ok()); +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct AtUri { +    pub repository: DidOrHost, +    pub collection: Option<String>, +    pub record: Option<String>, +    pub fragment: Option<String>, +} + +impl FromStr for AtUri { +    type Err = anyhow::Error; + +    fn from_str(s: &str) -> Result<Self, Self::Err> { +        lazy_static! { +            static ref ATURI_RE: Regex = Regex::new(r"^at://([a-zA-Z0-9:_\.-]+)(/([a-zA-Z0-9\.]+))?(/([a-zA-Z0-9\.-]+))?(#([a-zA-Z0-9/-]+))?$").unwrap(); +        } +        if let Some(caps) = ATURI_RE.captures(s) { +            let uri = AtUri { +                repository: DidOrHost::from_str(&caps[1])?, +                collection: caps.get(3).map(|v| v.as_str().to_string()), +                record: caps.get(5).map(|v| v.as_str().to_string()), +                fragment: caps.get(7).map(|v| v.as_str().to_string()), +            }; +            Ok(uri) +        } else { +            Err(anyhow!("couldn't parse as an at:// URI: {}", s)) +        } +    } +} + +impl fmt::Display for AtUri { +    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +        write!(f, "at://{}", self.repository)?; +        if let Some(ref c) = self.collection { +            write!(f, "/{}", c)?; +        }; +        if let Some(ref r) = self.record { +            write!(f, "/{}", r)?; +        }; +        if let Some(ref v) = self.fragment { +            write!(f, "#{}", v)?; +        }; +        Ok(()) +    } +} + +#[test] +fn test_aturi() { +    assert!(AtUri::from_str("at://bob.com").is_ok()); +    assert!(AtUri::from_str("at://did:plc:bv6ggog3tya2z3vxsub7hnal").is_ok()); +    assert!(AtUri::from_str("at://bob.com/io.example.song").is_ok()); +    assert!(AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a").is_ok()); +    assert!(AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a#/title").is_ok()); + +    let uri = AtUri { +        repository: DidOrHost::Did("some".to_string(), "thing".to_string()), +        collection: Some("com.atproto.record".to_string()), +        record: Some("asdf-123".to_string()), +        fragment: Some("/path".to_string()), +    }; +    assert_eq!( +        "at://did:some:thing/com.atproto.record/asdf-123#/path", +        uri.to_string() +    ); +    println!("{:?}", AtUri::from_str(&uri.to_string())); +    assert!(AtUri::from_str(&uri.to_string()).is_ok()); + +    let uri = AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a#/title").unwrap(); +    assert_eq!(uri.repository, DidOrHost::Host("bob.com".to_string())); +    assert_eq!(uri.collection, Some("io.example.song".to_string())); +    assert_eq!(uri.record, Some("3yI5-c1z-cc2p-1a".to_string())); +    assert_eq!(uri.fragment, Some("/title".to_string())); +} diff --git a/adenosine-cli/src/lib.rs b/adenosine-cli/src/lib.rs index 06fade5..8f462da 100644 --- a/adenosine-cli/src/lib.rs +++ b/adenosine-cli/src/lib.rs @@ -6,6 +6,9 @@ use std::collections::HashMap;  use std::str::FromStr;  use std::time::Duration; +mod identifiers; +pub use identifiers::{AtUri, DidOrHost}; +  static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);  #[derive(Debug, PartialEq, Eq, Clone)] | 
