aboutsummaryrefslogtreecommitdiffstats
path: root/adenosine-cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'adenosine-cli/src')
-rw-r--r--adenosine-cli/src/bin/adenosine.rs3
-rw-r--r--adenosine-cli/src/identifiers.rs418
-rw-r--r--adenosine-cli/src/lib.rs243
3 files changed, 2 insertions, 662 deletions
diff --git a/adenosine-cli/src/bin/adenosine.rs b/adenosine-cli/src/bin/adenosine.rs
index 21fdf31..4164181 100644
--- a/adenosine-cli/src/bin/adenosine.rs
+++ b/adenosine-cli/src/bin/adenosine.rs
@@ -1,4 +1,5 @@
-use adenosine_cli::identifiers::*;
+use adenosine::identifiers::*;
+use adenosine::*;
use adenosine_cli::*;
use anyhow::anyhow;
use serde_json::{json, Value};
diff --git a/adenosine-cli/src/identifiers.rs b/adenosine-cli/src/identifiers.rs
deleted file mode 100644
index 7e93d71..0000000
--- a/adenosine-cli/src/identifiers.rs
+++ /dev/null
@@ -1,418 +0,0 @@
-use anyhow::{anyhow, Result};
-use lazy_static::lazy_static;
-use regex::Regex;
-use std::fmt;
-use std::ops::Deref;
-use std::str::FromStr;
-use std::time::SystemTime;
-
-#[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("multi.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(4).map(|v| v.as_str().to_string()),
- record: caps.get(7).map(|v| v.as_str().to_string()),
- fragment: caps.get(9).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://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/").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());
- assert!(
- AtUri::from_str("at://did:plc:ltk4reuh7rkoy2frnueetpb5/app.bsky.follow/3jg23pbmlhc2a")
- .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()));
-
- let uri = AtUri::from_str("at://bob.com/io.example.song/").unwrap();
- assert_eq!(uri.repository, DidOrHost::Host("bob.com".to_string()));
- assert_eq!(uri.collection, Some("io.example.song".to_string()));
-}
-
-/// A String (newtype) representing an NSID
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
-pub struct Nsid(String);
-
-impl FromStr for Nsid {
- type Err = anyhow::Error;
-
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- lazy_static! {
- static ref NSID_RE: Regex = Regex::new(r"^([a-z][a-z0-9-]+\.)+[a-zA-Z0-9-]+$").unwrap();
- }
- if NSID_RE.is_match(s) {
- Ok(Self(s.to_string()))
- } else {
- Err(anyhow!("does not match as an NSID: {}", s))
- }
- }
-}
-
-impl Deref for Nsid {
- type Target = str;
-
- fn deref(&self) -> &Self::Target {
- &self.0
- }
-}
-
-impl Nsid {
- pub fn domain(&self) -> String {
- self.rsplit('.').skip(1).collect::<Vec<&str>>().join(".")
- }
-
- pub fn name(&self) -> String {
- self.split('.')
- .last()
- .expect("multiple segments in NSID")
- .to_string()
- }
-}
-
-impl fmt::Display for Nsid {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-#[test]
-fn test_nsid() {
- assert!(Nsid::from_str("com.atproto.recordType").is_ok());
-
- let nsid = Nsid::from_str("com.atproto.recordType").unwrap();
- assert_eq!(nsid.domain(), "atproto.com".to_string());
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
-pub struct Did(String);
-
-impl FromStr for Did {
- type Err = anyhow::Error;
-
- /// DID syntax is specified in: <https://w3c.github.io/did-core/#did-syntax>
- ///
- /// This regex does not follow that definition exactly.
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- lazy_static! {
- static ref DID_RE: Regex =
- Regex::new(r"^did:([a-z]{1,32}):([a-zA-Z0-9\-.]{1,256})$").unwrap();
- }
- if DID_RE.is_match(s) {
- Ok(Self(s.to_string()))
- } else {
- Err(anyhow!("does not match as a DID: {}", s))
- }
- }
-}
-
-impl Deref for Did {
- type Target = str;
-
- fn deref(&self) -> &Self::Target {
- &self.0
- }
-}
-
-impl Did {
- pub fn did_type(&self) -> String {
- self.split(':').nth(1).unwrap().to_string()
- }
-}
-
-impl fmt::Display for Did {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-#[test]
-fn test_did() {
- assert!(Did::from_str("did:web:asdf.org").is_ok());
- assert!(Did::from_str("did:plc:asdf").is_ok());
-
- assert!(Did::from_str("bob.com").is_err());
- assert!(Did::from_str("").is_err());
- assert!(Did::from_str("did:").is_err());
- assert!(Did::from_str("did:plc:").is_err());
- assert!(Did::from_str("plc:asdf").is_err());
- assert!(Did::from_str("DID:thing:thang").is_err());
-
- assert_eq!(
- Did::from_str("did:web:asdf.org").unwrap().did_type(),
- "web".to_string()
- );
-}
-
-lazy_static! {
- /// Sortable base32 encoding, as bluesky implements/defines
- static ref BASE32SORT: data_encoding::Encoding = {
- let mut spec = data_encoding::Specification::new();
- spec.symbols.push_str("234567abcdefghijklmnopqrstuvwxyz");
- spec.padding = None;
- spec.encoding().unwrap()
- };
-}
-
-/// A string identifier for individual records, based on UNIX timestamp in microseconds.
-///
-/// See also: https://github.com/bluesky-social/atproto/issues/334
-///
-/// Pretty permissive about what can be parsed/accepted, because there were some old TIDs floating
-/// around with weird format.
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
-pub struct Tid(String);
-
-impl FromStr for Tid {
- type Err = anyhow::Error;
-
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- lazy_static! {
- static ref TID_RE: Regex = Regex::new(r"^[0-9a-zA-Z-]{13,20}$").unwrap();
- }
- if TID_RE.is_match(s) {
- Ok(Self(s.to_string()))
- } else {
- Err(anyhow!("does not match as a TID: {}", s))
- }
- }
-}
-
-impl Deref for Tid {
- type Target = str;
-
- fn deref(&self) -> &Self::Target {
- &self.0
- }
-}
-
-impl fmt::Display for Tid {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl Tid {
- pub fn new(micros: u64, clock_id: u16) -> Self {
- // 53 bits of millis
- let micros = micros & 0x001FFFFFFFFFFFFF;
- // 10 bits of clock ID
- let clock_id = clock_id & 0x03FF;
- let val: u64 = (micros << 10) | (clock_id as u64);
- // big-endian encoding
- let enc = BASE32SORT.encode(&val.to_be_bytes());
- Tid(format!(
- "{}-{}-{}-{}",
- &enc[0..4],
- &enc[4..7],
- &enc[7..11],
- &enc[11..13]
- ))
- }
-}
-
-#[test]
-fn test_tid() {
- Tid::from_str("3yI5-c1z-cc2p-1a").unwrap();
- assert!(Tid::from_str("3jg6anbimrc2a").is_ok());
- assert!(Tid::from_str("3yI5-c1z-cc2p-1a").is_ok());
-
- Tid::from_str("asdf234as4asdf234").unwrap();
- assert!(Tid::from_str("asdf234as4asdf234").is_ok());
-
- assert!(Tid::from_str("").is_err());
- assert!(Tid::from_str("com").is_err());
- assert!(Tid::from_str("com.blah.Thing").is_err());
- assert!(Tid::from_str("did:stuff:blah").is_err());
-
- let t1 = Tid::new(0, 0);
- assert_eq!(t1.to_string(), "2222-222-2222-22".to_string());
-}
-
-/// TID Generator
-///
-/// This version uses 53-bit microsecond counter (since UNIX epoch), and a random 10-bit clock id.
-///
-/// If the current timestamp is not greater than the last timestamp (either because clock did not
-/// advance monotonically, or multiple TIDs were generated in the same microsecond (very unlikely),
-/// the timestamp is simply incremented.
-pub struct Ticker {
- last_timestamp: u64,
- clock_id: u16,
-}
-
-impl Ticker {
- pub fn new() -> Self {
- let mut ticker = Self {
- last_timestamp: 0,
- // mask to 10 bits
- clock_id: rand::random::<u16>() & 0x03FF,
- };
- // prime the pump
- ticker.next_tid();
- ticker
- }
-
- pub fn next_tid(&mut self) -> Tid {
- let now = SystemTime::now()
- .duration_since(SystemTime::UNIX_EPOCH)
- .expect("timestamp in micros since UNIX epoch")
- .as_micros() as u64;
- // mask to 53 bits
- let now = now & 0x001FFFFFFFFFFFFF;
- if now > self.last_timestamp {
- self.last_timestamp = now;
- } else {
- self.last_timestamp += 1;
- }
- Tid::new(self.last_timestamp, self.clock_id)
- }
-}
-
-impl Default for Ticker {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[test]
-fn test_ticker() {
- let mut ticker = Ticker::new();
- let mut prev = ticker.next_tid();
- let mut next = ticker.next_tid();
- for _ in [0..100] {
- println!("{} >? {}", next, prev);
- assert!(next > prev);
- prev = next;
- next = ticker.next_tid();
- }
- println!("{}", prev);
- assert_eq!(prev, Tid::from_str(&prev.to_string()).unwrap());
- assert_eq!(next[13..16], prev[13..16]);
-
- let mut other_ticker = Ticker::new();
- let other = other_ticker.next_tid();
- assert!(other > next);
- assert!(next[13..16] != other[13..16]);
-}
diff --git a/adenosine-cli/src/lib.rs b/adenosine-cli/src/lib.rs
index 0db2cb4..3eb56cc 100644
--- a/adenosine-cli/src/lib.rs
+++ b/adenosine-cli/src/lib.rs
@@ -2,226 +2,9 @@ use anyhow::anyhow;
pub use anyhow::Result;
use lazy_static::lazy_static;
use regex::Regex;
-use reqwest::header;
use serde_json::Value;
use std::collections::HashMap;
use std::str::FromStr;
-use std::time::Duration;
-
-pub mod identifiers;
-use identifiers::Nsid;
-
-static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
-
-#[derive(Debug, PartialEq, Eq, Clone)]
-pub enum XrpcMethod {
- Get,
- Post,
-}
-
-impl FromStr for XrpcMethod {
- type Err = anyhow::Error;
-
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- match s {
- "get" => Ok(XrpcMethod::Get),
- "post" => Ok(XrpcMethod::Post),
- _ => Err(anyhow!("unknown method: {}", s)),
- }
- }
-}
-
-pub struct XrpcClient {
- http_client: reqwest::blocking::Client,
- host: String,
-}
-
-impl XrpcClient {
- pub fn new(host: String, auth_token: Option<String>) -> Result<Self> {
- let mut headers = header::HeaderMap::new();
- if let Some(token) = &auth_token {
- let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?;
- auth_value.set_sensitive(true);
- headers.insert(header::AUTHORIZATION, auth_value);
- };
-
- let http_client = reqwest::blocking::Client::builder()
- .default_headers(headers)
- .user_agent(APP_USER_AGENT)
- .timeout(Duration::from_secs(30))
- //.danger_accept_invalid_certs(true)
- .build()
- .expect("ERROR :: Could not build reqwest client");
-
- Ok(XrpcClient { http_client, host })
- }
-
- pub fn get(
- &self,
- nsid: &Nsid,
- params: Option<HashMap<String, String>>,
- ) -> Result<Option<Value>> {
- log::debug!("XRPC GET endpoint={} params={:?}", nsid, params);
- let params: HashMap<String, String> = params.unwrap_or_default();
- let res = self
- .http_client
- .get(format!("{}/xrpc/{}", self.host, nsid))
- .query(&params)
- .send()?;
- // TODO: refactor this error handling stuff into single method
- if res.status() == 400 {
- let val: Value = res.json()?;
- return Err(anyhow!(
- "XRPC Bad Request (400): {}",
- val["message"].as_str().unwrap_or("unknown")
- ));
- } else if res.status() == 500 {
- let val: Value = res.json()?;
- return Err(anyhow!(
- "XRPC Internal Error (500): {}",
- val["message"].as_str().unwrap_or("unknown")
- ));
- }
- let res = res.error_for_status()?;
- Ok(res.json()?)
- }
-
- pub fn get_to_writer<W: std::io::Write>(
- &self,
- nsid: &Nsid,
- params: Option<HashMap<String, String>>,
- output: &mut W,
- ) -> Result<u64> {
- let params: HashMap<String, String> = params.unwrap_or_default();
- let res = self
- .http_client
- .get(format!("{}/xrpc/{}", self.host, nsid))
- .query(&params)
- .send()?;
- if res.status() == 400 {
- let val: Value = res.json()?;
- return Err(anyhow!(
- "XRPC Bad Request (400): {}",
- val["message"].as_str().unwrap_or("unknown")
- ));
- } else if res.status() == 500 {
- let val: Value = res.json()?;
- return Err(anyhow!(
- "XRPC Internal Error (500): {}",
- val["message"].as_str().unwrap_or("unknown")
- ));
- }
- let mut res = res.error_for_status()?;
- Ok(res.copy_to(output)?)
- }
-
- pub fn post(
- &self,
- nsid: &Nsid,
- params: Option<HashMap<String, String>>,
- body: Option<Value>,
- ) -> Result<Option<Value>> {
- let params: HashMap<String, String> = params.unwrap_or_default();
- log::debug!(
- "XRPC POST endpoint={} params={:?} body={:?}",
- nsid,
- params,
- body
- );
- let mut req = self
- .http_client
- .post(format!("{}/xrpc/{}", self.host, nsid))
- .query(&params);
- req = if let Some(b) = body {
- req.json(&b)
- } else {
- req
- };
- let res = req.send()?;
- if res.status() == 400 {
- let val: Value = res.json()?;
- return Err(anyhow!(
- "XRPC Bad Request (400): {}",
- val["message"].as_str().unwrap_or("unknown")
- ));
- } else if res.status() == 500 {
- let val: Value = res.json()?;
- return Err(anyhow!(
- "XRPC Internal Error (500): {}",
- val["message"].as_str().unwrap_or("unknown")
- ));
- }
- let res = res.error_for_status()?;
- if res.content_length() == Some(0) {
- Ok(None)
- } else {
- Ok(res.json()?)
- }
- }
-
- pub fn post_cbor_from_reader<R: std::io::Read>(
- &self,
- nsid: &Nsid,
- params: Option<HashMap<String, String>>,
- input: &mut R,
- ) -> Result<Option<Value>> {
- let params: HashMap<String, String> = params.unwrap_or_default();
- let mut buf: Vec<u8> = Vec::new();
- input.read_to_end(&mut buf)?;
- let res = self
- .http_client
- .post(format!("{}/xrpc/{}", self.host, nsid))
- .query(&params)
- .header(reqwest::header::CONTENT_TYPE, "application/cbor")
- .body(buf)
- .send()?;
- if res.status() == 400 {
- let val: Value = res.json()?;
- return Err(anyhow!(
- "XRPC Bad Request: {}",
- val["message"].as_str().unwrap_or("unknown")
- ));
- }
- let res = res.error_for_status()?;
- Ok(res.json()?)
- }
-
- // reqwest::blocking::Body
-}
-
-/// Tries to parse a DID internal identifier from a JWT (as base64-encoded token)
-pub fn parse_did_from_jwt(jwt: &str) -> Result<String> {
- let second_b64 = jwt.split('.').nth(1).ok_or(anyhow!("couldn't parse JWT"))?;
- let second_json: Vec<u8> = base64::decode_config(second_b64, base64::URL_SAFE)?;
- let obj: Value = serde_json::from_slice(&second_json)?;
- // trying to also support pulling "aud" as DID; not sure this is actually correct use of
- // UCAN/JWT semantics?
- let did = obj["sub"]
- .as_str()
- .or(obj["aud"].as_str())
- .ok_or(anyhow!("couldn't find DID subject in JWT"))?
- .to_string();
- if !did.starts_with("did:") {
- return Err(anyhow!("couldn't find DID subject in JWT"));
- }
- Ok(did)
-}
-
-#[test]
-fn test_parse_jwt() {
- assert!(parse_did_from_jwt(".").is_err());
- // JWT from atproto ("sub")
- assert_eq!(
- parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(),
- "did:plc:ex3si527cd2aogbvidkooozc",
- );
- // UCAN from adenosine-pds ("aud")
- assert_eq!(
- parse_did_from_jwt("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4wLWNhbmFyeSJ9.eyJhdHQiOltdLCJhdWQiOiJkaWQ6cGxjOnM3b25ieWphN2MzeXJzZ3Zob2xrbHM1YiIsImV4cCI6MTY3NTM4Mzg2NywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6RG5hZWRHVGJkb0Frb1NlOG96a3k1WHAzMjZTVFpUSm50aDlHY2dxaTZQYjNzYjczIiwibm5jIjoiTnZURDhENWZjNXFpalIyMWJ1V2Z1ZE02dzlBM2drSy1ac3RtUW03b21pdyIsInByZiI6W119.QwZkb9R17tNhXnY_roqFYgdiIgUnSC18FYWQb3PcH6BU1R5l4W_T4XdACyczPGfM-jAnF2r2loBXDntYVS6N5A").unwrap(),
- "did:plc:s7onbyja7c3yrsgvholkls5b",
- );
- assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err());
-}
/// Represents fields/content specified on the command line.
///
@@ -322,29 +105,3 @@ pub fn value_from_fields(fields: Vec<ArgField>) -> Value {
}
Value::Object(serde_json::map::Map::from_iter(map.into_iter()))
}
-
-/// Helper to generate the current timestamp as right now, UTC, formatted as an RFC 3339 string.
-///
-/// Currently, bluesky PDS expects millisecond precision, so we use that.
-///
-/// Returns something like "2022-11-22T09:21:15.640Z"
-pub fn created_at_now() -> String {
- let now = time::OffsetDateTime::now_utc();
- // remove microsecond precision, but retain millisecond precision
- let ms = now.millisecond();
- let now = now.replace_microsecond(0).unwrap();
- let now = now.replace_millisecond(ms).unwrap();
- now.format(&time::format_description::well_known::Rfc3339)
- .unwrap()
-}
-
-#[test]
-fn test_created_at_now() {
- // eg: 2022-11-22T09:20:44.123Z
- let ts = created_at_now();
- println!("{}", ts);
- assert_eq!(&ts[4..5], "-");
- assert_eq!(&ts[7..8], "-");
- assert_eq!(&ts[10..11], "T");
- assert_eq!(&ts[23..24], "Z");
-}