From 5e7193e9b2ae8eacd19b669e2ace72510622860b Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Mon, 19 Feb 2024 03:02:27 +0000 Subject: [PATCH 1/2] atrium-api: Add `RecordKey` type --- atrium-api/CHANGELOG.md | 1 + atrium-api/src/types.rs | 104 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/atrium-api/CHANGELOG.md b/atrium-api/CHANGELOG.md index 0b0bbda9..87691246 100644 --- a/atrium-api/CHANGELOG.md +++ b/atrium-api/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `atrium_api::types`: + - `RecordKey` - `LimitedU8`, `LimitedNonZeroU8`, `BoundedU8` - `LimitedU16`, `LimitedNonZeroU16`, `BoundedU16` - `LimitedU32`, `LimitedNonZeroU32`, `BoundedU32` diff --git a/atrium-api/src/types.rs b/atrium-api/src/types.rs index a65b28bc..e0b7296c 100644 --- a/atrium-api/src/types.rs +++ b/atrium-api/src/types.rs @@ -1,6 +1,10 @@ //! Definitions for AT Protocol's data models. //! +use std::{cell::OnceCell, ops::Deref, str::FromStr}; + +use regex::Regex; + #[cfg(feature = "dag-cbor")] mod cid_link_ipld; #[cfg(not(feature = "dag-cbor"))] @@ -16,6 +20,68 @@ pub use integer::*; pub mod string; +/// A record key (`rkey`) used to name and reference an individual record within the same +/// collection of an atproto repository. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct RecordKey(String); + +impl RecordKey { + /// Returns the record key as a string slice. + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl FromStr for RecordKey { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + const RE_RKEY: OnceCell = OnceCell::new(); + + if [".", ".."].contains(&s) { + Err("Disallowed rkey") + } else if !RE_RKEY + .get_or_init(|| Regex::new(r"^[a-zA-Z0-9._~-]{1,512}$").unwrap()) + .is_match(&s) + { + Err("Invalid rkey") + } else { + Ok(Self(s.into())) + } + } +} + +impl<'de> serde::Deserialize<'de> for RecordKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + let value = serde::Deserialize::deserialize(deserializer)?; + Self::from_str(value).map_err(D::Error::custom) + } +} + +impl Into for RecordKey { + fn into(self) -> String { + self.0 + } +} + +impl AsRef for RecordKey { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for RecordKey { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + /// Definitions for Blob types. /// Usually a map with `$type` is used, but deprecated legacy formats are also supported for parsing. /// @@ -58,6 +124,44 @@ mod tests { const CID_LINK_JSON: &str = r#"{"$link":"bafkreibme22gw2h7y2h7tg2fhqotaqjucnbc24deqo72b6mkl2egezxhvy"}"#; + #[test] + fn valid_rkey() { + // From https://atproto.com/specs/record-key#examples + for valid in &["3jui7kd54zh2y", "self", "example.com", "~1.2-3_", "dHJ1ZQ"] { + assert!( + from_str::(&format!("\"{}\"", valid)).is_ok(), + "valid rkey `{}` parsed as invalid", + valid, + ); + } + } + + #[test] + fn invalid_rkey() { + // From https://atproto.com/specs/record-key#examples + for invalid in &[ + "literal:self", + "alpha/beta", + ".", + "..", + "#extra", + "@handle", + "any space", + "any+space", + "number[3]", + "number(3)", + "\"quote\"", + "pre:fix", + "dHJ1ZQ==", + ] { + assert!( + from_str::(&format!("\"{}\"", invalid)).is_err(), + "invalid rkey `{}` parsed as valid", + invalid, + ); + } + } + #[test] fn test_cid_link_serde_json() { let deserialized = From 9d33c92a77f64ee6eecc1b85e421edf866ab9684 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Mon, 19 Feb 2024 03:06:41 +0000 Subject: [PATCH 2/2] Introduce dedicated types for more Lexicon string formats - `datetime` - `nsid` - `language` --- Cargo.lock | 12 + Cargo.toml | 3 +- atrium-api/CHANGELOG.md | 3 + atrium-api/Cargo.toml | 2 + atrium-api/src/app/bsky/actor/defs.rs | 6 +- atrium-api/src/app/bsky/embed/record.rs | 2 +- atrium-api/src/app/bsky/feed/defs.rs | 6 +- atrium-api/src/app/bsky/feed/generator.rs | 2 +- atrium-api/src/app/bsky/feed/get_likes.rs | 4 +- atrium-api/src/app/bsky/feed/like.rs | 2 +- atrium-api/src/app/bsky/feed/post.rs | 4 +- atrium-api/src/app/bsky/feed/repost.rs | 2 +- atrium-api/src/app/bsky/feed/threadgate.rs | 2 +- atrium-api/src/app/bsky/graph/block.rs | 2 +- atrium-api/src/app/bsky/graph/defs.rs | 4 +- atrium-api/src/app/bsky/graph/follow.rs | 2 +- atrium-api/src/app/bsky/graph/list.rs | 2 +- atrium-api/src/app/bsky/graph/listblock.rs | 2 +- atrium-api/src/app/bsky/graph/listitem.rs | 2 +- .../app/bsky/notification/get_unread_count.rs | 2 +- .../bsky/notification/list_notifications.rs | 6 +- .../src/app/bsky/notification/update_seen.rs | 2 +- atrium-api/src/com/atproto/admin/defs.rs | 42 +- .../atproto/admin/query_moderation_events.rs | 4 +- .../admin/query_moderation_statuses.rs | 8 +- atrium-api/src/com/atproto/label/defs.rs | 2 +- .../com/atproto/moderation/create_report.rs | 2 +- .../src/com/atproto/repo/apply_writes.rs | 6 +- .../src/com/atproto/repo/create_record.rs | 2 +- .../src/com/atproto/repo/delete_record.rs | 2 +- .../src/com/atproto/repo/describe_repo.rs | 2 +- atrium-api/src/com/atproto/repo/get_record.rs | 2 +- .../src/com/atproto/repo/list_records.rs | 2 +- atrium-api/src/com/atproto/repo/put_record.rs | 2 +- .../com/atproto/server/create_app_password.rs | 2 +- atrium-api/src/com/atproto/server/defs.rs | 4 +- .../com/atproto/server/list_app_passwords.rs | 2 +- atrium-api/src/com/atproto/sync/get_record.rs | 2 +- .../src/com/atproto/sync/subscribe_repos.rs | 8 +- atrium-api/src/types/string.rs | 375 +++++++++++++++++- atrium-cli/src/runner.rs | 9 +- lexicon/atrium-codegen/src/token_stream.rs | 3 + 42 files changed, 472 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57dc5985..75fa30d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,9 +400,11 @@ dependencies = [ "async-trait", "atrium-xrpc 0.8.0", "atrium-xrpc-client", + "chrono", "cid 0.10.1", "futures", "http", + "langtag", "libipld-core", "regex", "serde", @@ -659,6 +661,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.52.0", ] @@ -1632,6 +1635,15 @@ dependencies = [ "log", ] +[[package]] +name = "langtag" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed60c85f254d6ae8450cec15eedd921efbc4d1bdf6fcf6202b9a58b403f6f805" +dependencies = [ + "serde", +] + [[package]] name = "lazy_static" version = "1.4.0" diff --git a/Cargo.toml b/Cargo.toml index 29d8ab14..b799e350 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ libipld-core = "0.16" serde_ipld_dagcbor = "0.3" # Parsing and validation +chrono = "0.4" +langtag = "0.3" regex = "1" serde = "1.0.160" serde_bytes = "0.11.9" @@ -58,7 +60,6 @@ anyhow = "1.0.71" thiserror = "1" # CLI -chrono = "0.4.24" clap = { version = "4.2.4", features = ["derive"] } dirs = "5.0.1" diff --git a/atrium-api/CHANGELOG.md b/atrium-api/CHANGELOG.md index 87691246..8bce6101 100644 --- a/atrium-api/CHANGELOG.md +++ b/atrium-api/CHANGELOG.md @@ -21,8 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All Lexicon string fields with one of the following formats now have the corresponding dedicated type, instead of `String`: - `at-identifier` (`atrium_api::types::string::AtIdentifier`) + - `datetime` (`atrium_api::types::string::Datetime`) - `did` (`atrium_api::types::string::Did`) - `handle` (`atrium_api::types::string::Handle`) + - `nsid` (`atrium_api::types::string::Nsid`) + - `language` (`atrium_api::types::string::Language`) ## [0.16.0](https://github.com/sugyan/atrium/compare/atrium-api-v0.15.0...atrium-api-v0.16.0) - 2024-02-09 diff --git a/atrium-api/Cargo.toml b/atrium-api/Cargo.toml index 348f6752..358018e5 100644 --- a/atrium-api/Cargo.toml +++ b/atrium-api/Cargo.toml @@ -14,7 +14,9 @@ keywords.workspace = true [dependencies] atrium-xrpc.workspace = true async-trait.workspace = true +chrono = { workspace = true, features = ["serde"] } http.workspace = true +langtag = { workspace = true, features = ["serde"] } regex.workspace = true serde = { workspace = true, features = ["derive"] } serde_bytes.workspace = true diff --git a/atrium-api/src/app/bsky/actor/defs.rs b/atrium-api/src/app/bsky/actor/defs.rs index 49181b25..523adc65 100644 --- a/atrium-api/src/app/bsky/actor/defs.rs +++ b/atrium-api/src/app/bsky/actor/defs.rs @@ -43,7 +43,7 @@ pub struct InterestsPref { pub struct PersonalDetailsPref { ///The birth date of account owner. #[serde(skip_serializing_if = "Option::is_none")] - pub birth_date: Option, + pub birth_date: Option, } pub type Preferences = Vec; #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] @@ -58,7 +58,7 @@ pub struct ProfileView { pub display_name: Option, pub handle: crate::types::string::Handle, #[serde(skip_serializing_if = "Option::is_none")] - pub indexed_at: Option, + pub indexed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -96,7 +96,7 @@ pub struct ProfileViewDetailed { pub follows_count: Option, pub handle: crate::types::string::Handle, #[serde(skip_serializing_if = "Option::is_none")] - pub indexed_at: Option, + pub indexed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/atrium-api/src/app/bsky/embed/record.rs b/atrium-api/src/app/bsky/embed/record.rs index 3bc77af8..d8738b77 100644 --- a/atrium-api/src/app/bsky/embed/record.rs +++ b/atrium-api/src/app/bsky/embed/record.rs @@ -31,7 +31,7 @@ pub struct ViewRecord { pub cid: String, #[serde(skip_serializing_if = "Option::is_none")] pub embeds: Option>, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, pub uri: String, diff --git a/atrium-api/src/app/bsky/feed/defs.rs b/atrium-api/src/app/bsky/feed/defs.rs index 4aef200a..bde697dc 100644 --- a/atrium-api/src/app/bsky/feed/defs.rs +++ b/atrium-api/src/app/bsky/feed/defs.rs @@ -36,7 +36,7 @@ pub struct GeneratorView { pub description_facets: Option>, pub did: crate::types::string::Did, pub display_name: String, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub like_count: Option, pub uri: String, @@ -62,7 +62,7 @@ pub struct PostView { pub cid: String, #[serde(skip_serializing_if = "Option::is_none")] pub embed: Option, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -82,7 +82,7 @@ pub struct PostView { #[serde(rename_all = "camelCase")] pub struct ReasonRepost { pub by: crate::app::bsky::actor::defs::ProfileViewBasic, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/atrium-api/src/app/bsky/feed/generator.rs b/atrium-api/src/app/bsky/feed/generator.rs index 3fc18629..df66368a 100644 --- a/atrium-api/src/app/bsky/feed/generator.rs +++ b/atrium-api/src/app/bsky/feed/generator.rs @@ -5,7 +5,7 @@ pub struct Record { #[serde(skip_serializing_if = "Option::is_none")] pub avatar: Option, - pub created_at: String, + pub created_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/atrium-api/src/app/bsky/feed/get_likes.rs b/atrium-api/src/app/bsky/feed/get_likes.rs index bf413eba..dcf8b157 100644 --- a/atrium-api/src/app/bsky/feed/get_likes.rs +++ b/atrium-api/src/app/bsky/feed/get_likes.rs @@ -30,6 +30,6 @@ pub enum Error {} #[serde(rename_all = "camelCase")] pub struct Like { pub actor: crate::app::bsky::actor::defs::ProfileView, - pub created_at: String, - pub indexed_at: String, + pub created_at: crate::types::string::Datetime, + pub indexed_at: crate::types::string::Datetime, } diff --git a/atrium-api/src/app/bsky/feed/like.rs b/atrium-api/src/app/bsky/feed/like.rs index ad263b20..54597025 100644 --- a/atrium-api/src/app/bsky/feed/like.rs +++ b/atrium-api/src/app/bsky/feed/like.rs @@ -3,6 +3,6 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Record { - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub subject: crate::com::atproto::repo::strong_ref::Main, } diff --git a/atrium-api/src/app/bsky/feed/post.rs b/atrium-api/src/app/bsky/feed/post.rs index b31d1cbf..1bd9796e 100644 --- a/atrium-api/src/app/bsky/feed/post.rs +++ b/atrium-api/src/app/bsky/feed/post.rs @@ -4,7 +4,7 @@ #[serde(rename_all = "camelCase")] pub struct Record { ///Client-declared timestamp when this post was originally created. - pub created_at: String, + pub created_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub embed: Option, ///DEPRECATED: replaced by app.bsky.richtext.facet. @@ -18,7 +18,7 @@ pub struct Record { pub labels: Option, ///Indicates human language of post primary text content. #[serde(skip_serializing_if = "Option::is_none")] - pub langs: Option>, + pub langs: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub reply: Option, ///Additional hashtags, in addition to any included in post text and facets. diff --git a/atrium-api/src/app/bsky/feed/repost.rs b/atrium-api/src/app/bsky/feed/repost.rs index 8378a6f3..d85bfaa4 100644 --- a/atrium-api/src/app/bsky/feed/repost.rs +++ b/atrium-api/src/app/bsky/feed/repost.rs @@ -3,6 +3,6 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Record { - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub subject: crate::com::atproto::repo::strong_ref::Main, } diff --git a/atrium-api/src/app/bsky/feed/threadgate.rs b/atrium-api/src/app/bsky/feed/threadgate.rs index 77039837..b0ce1b11 100644 --- a/atrium-api/src/app/bsky/feed/threadgate.rs +++ b/atrium-api/src/app/bsky/feed/threadgate.rs @@ -5,7 +5,7 @@ pub struct Record { #[serde(skip_serializing_if = "Option::is_none")] pub allow: Option>, - pub created_at: String, + pub created_at: crate::types::string::Datetime, ///Reference (AT-URI) to the post record. pub post: String, } diff --git a/atrium-api/src/app/bsky/graph/block.rs b/atrium-api/src/app/bsky/graph/block.rs index 70c1eb13..512c34c4 100644 --- a/atrium-api/src/app/bsky/graph/block.rs +++ b/atrium-api/src/app/bsky/graph/block.rs @@ -3,7 +3,7 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Record { - pub created_at: String, + pub created_at: crate::types::string::Datetime, ///DID of the account to be blocked. pub subject: crate::types::string::Did, } diff --git a/atrium-api/src/app/bsky/graph/defs.rs b/atrium-api/src/app/bsky/graph/defs.rs index f24c908e..8213d69f 100644 --- a/atrium-api/src/app/bsky/graph/defs.rs +++ b/atrium-api/src/app/bsky/graph/defs.rs @@ -20,7 +20,7 @@ pub struct ListView { pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description_facets: Option>, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, pub name: String, pub purpose: ListPurpose, pub uri: String, @@ -34,7 +34,7 @@ pub struct ListViewBasic { pub avatar: Option, pub cid: String, #[serde(skip_serializing_if = "Option::is_none")] - pub indexed_at: Option, + pub indexed_at: Option, pub name: String, pub purpose: ListPurpose, pub uri: String, diff --git a/atrium-api/src/app/bsky/graph/follow.rs b/atrium-api/src/app/bsky/graph/follow.rs index 00c1e2bc..6becc533 100644 --- a/atrium-api/src/app/bsky/graph/follow.rs +++ b/atrium-api/src/app/bsky/graph/follow.rs @@ -3,6 +3,6 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Record { - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub subject: crate::types::string::Did, } diff --git a/atrium-api/src/app/bsky/graph/list.rs b/atrium-api/src/app/bsky/graph/list.rs index 7d7a32d7..35331ba6 100644 --- a/atrium-api/src/app/bsky/graph/list.rs +++ b/atrium-api/src/app/bsky/graph/list.rs @@ -5,7 +5,7 @@ pub struct Record { #[serde(skip_serializing_if = "Option::is_none")] pub avatar: Option, - pub created_at: String, + pub created_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/atrium-api/src/app/bsky/graph/listblock.rs b/atrium-api/src/app/bsky/graph/listblock.rs index 96874cc6..3adf2006 100644 --- a/atrium-api/src/app/bsky/graph/listblock.rs +++ b/atrium-api/src/app/bsky/graph/listblock.rs @@ -3,7 +3,7 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Record { - pub created_at: String, + pub created_at: crate::types::string::Datetime, ///Reference (AT-URI) to the mod list record. pub subject: String, } diff --git a/atrium-api/src/app/bsky/graph/listitem.rs b/atrium-api/src/app/bsky/graph/listitem.rs index 8227561c..c7dda14b 100644 --- a/atrium-api/src/app/bsky/graph/listitem.rs +++ b/atrium-api/src/app/bsky/graph/listitem.rs @@ -3,7 +3,7 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Record { - pub created_at: String, + pub created_at: crate::types::string::Datetime, ///Reference (AT-URI) to the list record (app.bsky.graph.list). pub list: String, ///The account which is included on the list. diff --git a/atrium-api/src/app/bsky/notification/get_unread_count.rs b/atrium-api/src/app/bsky/notification/get_unread_count.rs index 53032d2d..dbbcdedb 100644 --- a/atrium-api/src/app/bsky/notification/get_unread_count.rs +++ b/atrium-api/src/app/bsky/notification/get_unread_count.rs @@ -4,7 +4,7 @@ #[serde(rename_all = "camelCase")] pub struct Parameters { #[serde(skip_serializing_if = "Option::is_none")] - pub seen_at: Option, + pub seen_at: Option, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/atrium-api/src/app/bsky/notification/list_notifications.rs b/atrium-api/src/app/bsky/notification/list_notifications.rs index c7a01b71..afb605fd 100644 --- a/atrium-api/src/app/bsky/notification/list_notifications.rs +++ b/atrium-api/src/app/bsky/notification/list_notifications.rs @@ -8,7 +8,7 @@ pub struct Parameters { #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub seen_at: Option, + pub seen_at: Option, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -17,7 +17,7 @@ pub struct Output { pub cursor: Option, pub notifications: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub seen_at: Option, + pub seen_at: Option, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(tag = "error", content = "message")] @@ -27,7 +27,7 @@ pub enum Error {} pub struct Notification { pub author: crate::app::bsky::actor::defs::ProfileView, pub cid: String, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, pub is_read: bool, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, diff --git a/atrium-api/src/app/bsky/notification/update_seen.rs b/atrium-api/src/app/bsky/notification/update_seen.rs index 224924ca..b75cf2a0 100644 --- a/atrium-api/src/app/bsky/notification/update_seen.rs +++ b/atrium-api/src/app/bsky/notification/update_seen.rs @@ -3,7 +3,7 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Input { - pub seen_at: String, + pub seen_at: crate::types::string::Datetime, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(tag = "error", content = "message")] diff --git a/atrium-api/src/com/atproto/admin/defs.rs b/atrium-api/src/com/atproto/admin/defs.rs index 418ff476..9c07d005 100644 --- a/atrium-api/src/com/atproto/admin/defs.rs +++ b/atrium-api/src/com/atproto/admin/defs.rs @@ -7,9 +7,9 @@ pub struct AccountView { #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub email_confirmed_at: Option, + pub email_confirmed_at: Option, pub handle: crate::types::string::Handle, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub invite_note: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -25,7 +25,7 @@ pub struct AccountView { #[serde(rename_all = "camelCase")] pub struct BlobView { pub cid: String, - pub created_at: String, + pub created_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub details: Option, pub mime_type: String, @@ -38,7 +38,7 @@ pub struct BlobView { pub struct CommunicationTemplateView { ///Subject of the message, used in emails. pub content_markdown: String, - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub disabled: bool, pub id: String, ///DID of the user who last updated the template. @@ -48,7 +48,7 @@ pub struct CommunicationTemplateView { ///Content of the template, can contain markdown and variable placeholders. #[serde(skip_serializing_if = "Option::is_none")] pub subject: Option, - pub updated_at: String, + pub updated_at: crate::types::string::Datetime, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -150,7 +150,7 @@ pub struct ModEventUnmute { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ModEventView { - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub created_by: crate::types::string::Did, #[serde(skip_serializing_if = "Option::is_none")] pub creator_handle: Option, @@ -164,7 +164,7 @@ pub struct ModEventView { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ModEventViewDetail { - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub created_by: crate::types::string::Did, pub event: ModEventViewDetailEventEnum, pub id: i64, @@ -188,7 +188,7 @@ pub struct ModerationDetail { pub struct RecordView { pub blob_cids: Vec, pub cid: String, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, pub moderation: Moderation, pub repo: RepoView, pub uri: String, @@ -199,7 +199,7 @@ pub struct RecordView { pub struct RecordViewDetail { pub blobs: Vec, pub cid: String, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, pub moderation: ModerationDetail, @@ -232,7 +232,7 @@ pub struct RepoView { #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, pub handle: crate::types::string::Handle, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub invite_note: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -249,9 +249,9 @@ pub struct RepoViewDetail { #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub email_confirmed_at: Option, + pub email_confirmed_at: Option, pub handle: crate::types::string::Handle, - pub indexed_at: String, + pub indexed_at: crate::types::string::Datetime, #[serde(skip_serializing_if = "Option::is_none")] pub invite_note: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -275,7 +275,7 @@ pub struct RepoViewNotFound { pub struct ReportView { #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub id: i64, pub reason_type: crate::com::atproto::moderation::defs::ReasonType, pub reported_by: crate::types::string::Did, @@ -289,7 +289,7 @@ pub struct ReportView { pub struct ReportViewDetail { #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub id: i64, pub reason_type: crate::com::atproto::moderation::defs::ReasonType, pub reported_by: crate::types::string::Did, @@ -322,19 +322,19 @@ pub struct SubjectStatusView { #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, ///Timestamp referencing the first moderation status impacting event was emitted on the subject - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub id: i64, ///Timestamp referencing when the author of the subject appealed a moderation action #[serde(skip_serializing_if = "Option::is_none")] - pub last_appealed_at: Option, + pub last_appealed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub last_reported_at: Option, + pub last_reported_at: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub last_reviewed_at: Option, + pub last_reviewed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub last_reviewed_by: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub mute_until: Option, + pub mute_until: Option, pub review_state: SubjectReviewState, pub subject: SubjectStatusViewSubjectEnum, #[serde(skip_serializing_if = "Option::is_none")] @@ -342,11 +342,11 @@ pub struct SubjectStatusView { #[serde(skip_serializing_if = "Option::is_none")] pub subject_repo_handle: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub suspend_until: Option, + pub suspend_until: Option, #[serde(skip_serializing_if = "Option::is_none")] pub takendown: Option, ///Timestamp referencing when the last update was made to the moderation status of the subject - pub updated_at: String, + pub updated_at: crate::types::string::Datetime, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/atrium-api/src/com/atproto/admin/query_moderation_events.rs b/atrium-api/src/com/atproto/admin/query_moderation_events.rs index 1ad41de6..1990e18c 100644 --- a/atrium-api/src/com/atproto/admin/query_moderation_events.rs +++ b/atrium-api/src/com/atproto/admin/query_moderation_events.rs @@ -11,10 +11,10 @@ pub struct Parameters { pub comment: Option, ///Retrieve events created after a given timestamp #[serde(skip_serializing_if = "Option::is_none")] - pub created_after: Option, + pub created_after: Option, ///Retrieve events created before a given timestamp #[serde(skip_serializing_if = "Option::is_none")] - pub created_before: Option, + pub created_before: Option, #[serde(skip_serializing_if = "Option::is_none")] pub created_by: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/atrium-api/src/com/atproto/admin/query_moderation_statuses.rs b/atrium-api/src/com/atproto/admin/query_moderation_statuses.rs index f5e2714a..549a640c 100644 --- a/atrium-api/src/com/atproto/admin/query_moderation_statuses.rs +++ b/atrium-api/src/com/atproto/admin/query_moderation_statuses.rs @@ -23,19 +23,19 @@ pub struct Parameters { pub limit: Option>, ///Search subjects reported after a given timestamp #[serde(skip_serializing_if = "Option::is_none")] - pub reported_after: Option, + pub reported_after: Option, ///Search subjects reported before a given timestamp #[serde(skip_serializing_if = "Option::is_none")] - pub reported_before: Option, + pub reported_before: Option, ///Specify when fetching subjects in a certain state #[serde(skip_serializing_if = "Option::is_none")] pub review_state: Option, ///Search subjects reviewed after a given timestamp #[serde(skip_serializing_if = "Option::is_none")] - pub reviewed_after: Option, + pub reviewed_after: Option, ///Search subjects reviewed before a given timestamp #[serde(skip_serializing_if = "Option::is_none")] - pub reviewed_before: Option, + pub reviewed_before: Option, #[serde(skip_serializing_if = "Option::is_none")] pub sort_direction: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/atrium-api/src/com/atproto/label/defs.rs b/atrium-api/src/com/atproto/label/defs.rs index a6756ed4..da3e8c53 100644 --- a/atrium-api/src/com/atproto/label/defs.rs +++ b/atrium-api/src/com/atproto/label/defs.rs @@ -8,7 +8,7 @@ pub struct Label { #[serde(skip_serializing_if = "Option::is_none")] pub cid: Option, ///Timestamp when this label was created. - pub cts: String, + pub cts: crate::types::string::Datetime, ///If true, this is a negation label, overwriting a previous label. #[serde(skip_serializing_if = "Option::is_none")] pub neg: Option, diff --git a/atrium-api/src/com/atproto/moderation/create_report.rs b/atrium-api/src/com/atproto/moderation/create_report.rs index de1a48bd..8397f930 100644 --- a/atrium-api/src/com/atproto/moderation/create_report.rs +++ b/atrium-api/src/com/atproto/moderation/create_report.rs @@ -13,7 +13,7 @@ pub struct Input { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Output { - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub id: i64, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, diff --git a/atrium-api/src/com/atproto/repo/apply_writes.rs b/atrium-api/src/com/atproto/repo/apply_writes.rs index c61ad47c..7c12c7fb 100644 --- a/atrium-api/src/com/atproto/repo/apply_writes.rs +++ b/atrium-api/src/com/atproto/repo/apply_writes.rs @@ -23,7 +23,7 @@ pub enum Error { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Create { - pub collection: String, + pub collection: crate::types::string::Nsid, #[serde(skip_serializing_if = "Option::is_none")] pub rkey: Option, pub value: crate::records::Record, @@ -32,14 +32,14 @@ pub struct Create { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Delete { - pub collection: String, + pub collection: crate::types::string::Nsid, pub rkey: String, } ///Operation which updates an existing record. #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Update { - pub collection: String, + pub collection: crate::types::string::Nsid, pub rkey: String, pub value: crate::records::Record, } diff --git a/atrium-api/src/com/atproto/repo/create_record.rs b/atrium-api/src/com/atproto/repo/create_record.rs index 3e203ca0..1e42096a 100644 --- a/atrium-api/src/com/atproto/repo/create_record.rs +++ b/atrium-api/src/com/atproto/repo/create_record.rs @@ -4,7 +4,7 @@ #[serde(rename_all = "camelCase")] pub struct Input { ///The NSID of the record collection. - pub collection: String, + pub collection: crate::types::string::Nsid, ///The record itself. Must contain a $type field. pub record: crate::records::Record, ///The handle or DID of the repo (aka, current account). diff --git a/atrium-api/src/com/atproto/repo/delete_record.rs b/atrium-api/src/com/atproto/repo/delete_record.rs index 3914c852..6157491f 100644 --- a/atrium-api/src/com/atproto/repo/delete_record.rs +++ b/atrium-api/src/com/atproto/repo/delete_record.rs @@ -4,7 +4,7 @@ #[serde(rename_all = "camelCase")] pub struct Input { ///The NSID of the record collection. - pub collection: String, + pub collection: crate::types::string::Nsid, ///The handle or DID of the repo (aka, current account). pub repo: crate::types::string::AtIdentifier, ///The Record Key. diff --git a/atrium-api/src/com/atproto/repo/describe_repo.rs b/atrium-api/src/com/atproto/repo/describe_repo.rs index f7f68bf6..8cda6c96 100644 --- a/atrium-api/src/com/atproto/repo/describe_repo.rs +++ b/atrium-api/src/com/atproto/repo/describe_repo.rs @@ -10,7 +10,7 @@ pub struct Parameters { #[serde(rename_all = "camelCase")] pub struct Output { ///List of all the collections (NSIDs) for which this repo contains at least one record. - pub collections: Vec, + pub collections: Vec, pub did: crate::types::string::Did, ///The complete DID document for this account. pub did_doc: crate::did_doc::DidDocument, diff --git a/atrium-api/src/com/atproto/repo/get_record.rs b/atrium-api/src/com/atproto/repo/get_record.rs index aa110447..8511cc50 100644 --- a/atrium-api/src/com/atproto/repo/get_record.rs +++ b/atrium-api/src/com/atproto/repo/get_record.rs @@ -7,7 +7,7 @@ pub struct Parameters { #[serde(skip_serializing_if = "Option::is_none")] pub cid: Option, ///The NSID of the record collection. - pub collection: String, + pub collection: crate::types::string::Nsid, ///The handle or DID of the repo. pub repo: crate::types::string::AtIdentifier, ///The Record Key. diff --git a/atrium-api/src/com/atproto/repo/list_records.rs b/atrium-api/src/com/atproto/repo/list_records.rs index 37f18af8..20eaa8cf 100644 --- a/atrium-api/src/com/atproto/repo/list_records.rs +++ b/atrium-api/src/com/atproto/repo/list_records.rs @@ -4,7 +4,7 @@ #[serde(rename_all = "camelCase")] pub struct Parameters { ///The NSID of the record type. - pub collection: String, + pub collection: crate::types::string::Nsid, #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, ///The number of records to return. diff --git a/atrium-api/src/com/atproto/repo/put_record.rs b/atrium-api/src/com/atproto/repo/put_record.rs index cf281002..d466aa46 100644 --- a/atrium-api/src/com/atproto/repo/put_record.rs +++ b/atrium-api/src/com/atproto/repo/put_record.rs @@ -4,7 +4,7 @@ #[serde(rename_all = "camelCase")] pub struct Input { ///The NSID of the record collection. - pub collection: String, + pub collection: crate::types::string::Nsid, ///The record to write. pub record: crate::records::Record, ///The handle or DID of the repo (aka, current account). diff --git a/atrium-api/src/com/atproto/server/create_app_password.rs b/atrium-api/src/com/atproto/server/create_app_password.rs index 38a3354d..07b51043 100644 --- a/atrium-api/src/com/atproto/server/create_app_password.rs +++ b/atrium-api/src/com/atproto/server/create_app_password.rs @@ -15,7 +15,7 @@ pub enum Error { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AppPassword { - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub name: String, pub password: String, } diff --git a/atrium-api/src/com/atproto/server/defs.rs b/atrium-api/src/com/atproto/server/defs.rs index 0af1397d..8bf3146e 100644 --- a/atrium-api/src/com/atproto/server/defs.rs +++ b/atrium-api/src/com/atproto/server/defs.rs @@ -5,7 +5,7 @@ pub struct InviteCode { pub available: i64, pub code: String, - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub created_by: String, pub disabled: bool, pub for_account: String, @@ -14,6 +14,6 @@ pub struct InviteCode { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct InviteCodeUse { - pub used_at: String, + pub used_at: crate::types::string::Datetime, pub used_by: crate::types::string::Did, } diff --git a/atrium-api/src/com/atproto/server/list_app_passwords.rs b/atrium-api/src/com/atproto/server/list_app_passwords.rs index 6f07dd6b..e0da4d94 100644 --- a/atrium-api/src/com/atproto/server/list_app_passwords.rs +++ b/atrium-api/src/com/atproto/server/list_app_passwords.rs @@ -13,6 +13,6 @@ pub enum Error { #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AppPassword { - pub created_at: String, + pub created_at: crate::types::string::Datetime, pub name: String, } diff --git a/atrium-api/src/com/atproto/sync/get_record.rs b/atrium-api/src/com/atproto/sync/get_record.rs index d84b66fd..2978936d 100644 --- a/atrium-api/src/com/atproto/sync/get_record.rs +++ b/atrium-api/src/com/atproto/sync/get_record.rs @@ -3,7 +3,7 @@ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Parameters { - pub collection: String, + pub collection: crate::types::string::Nsid, ///An optional past commit CID. #[serde(skip_serializing_if = "Option::is_none")] pub commit: Option, diff --git a/atrium-api/src/com/atproto/sync/subscribe_repos.rs b/atrium-api/src/com/atproto/sync/subscribe_repos.rs index dfb489a2..42ff6bcb 100644 --- a/atrium-api/src/com/atproto/sync/subscribe_repos.rs +++ b/atrium-api/src/com/atproto/sync/subscribe_repos.rs @@ -41,7 +41,7 @@ pub struct Commit { #[serde(skip_serializing_if = "Option::is_none")] pub since: Option, ///Timestamp of when this message was originally broadcast. - pub time: String, + pub time: crate::types::string::Datetime, ///Indicates that this commit contained too many ops, or data size was too large. Consumers will need to make a separate request to get missing data. pub too_big: bool, } @@ -52,7 +52,7 @@ pub struct Handle { pub did: crate::types::string::Did, pub handle: crate::types::string::Handle, pub seq: i64, - pub time: String, + pub time: crate::types::string::Datetime, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -69,7 +69,7 @@ pub struct Migrate { #[serde(skip_serializing_if = "Option::is_none")] pub migrate_to: Option, pub seq: i64, - pub time: String, + pub time: crate::types::string::Datetime, } ///A repo operation, ie a mutation of a single record. #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] @@ -87,7 +87,7 @@ pub struct RepoOp { pub struct Tombstone { pub did: crate::types::string::Did, pub seq: i64, - pub time: String, + pub time: crate::types::string::Datetime, } #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(tag = "$type")] diff --git a/atrium-api/src/types/string.rs b/atrium-api/src/types/string.rs index 7354f31a..3a98e5a0 100644 --- a/atrium-api/src/types/string.rs +++ b/atrium-api/src/types/string.rs @@ -2,10 +2,12 @@ //! //! [string formats]: https://atproto.com/specs/lexicon#string-formats -use std::{cell::OnceCell, ops::Deref, str::FromStr}; +use std::{cell::OnceCell, cmp, ops::Deref, str::FromStr}; +use chrono::DurationRound; +use langtag::{LanguageTag, LanguageTagBuf}; use regex::Regex; -use serde::{de::Error, Deserialize, Deserializer, Serialize}; +use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; /// Common trait implementations for Lexicon string formats that are newtype wrappers /// around `String`. @@ -101,6 +103,104 @@ impl AsRef for AtIdentifier { } } +/// A Lexicon timestamp. +#[derive(Clone, Debug, Eq)] +pub struct Datetime { + /// Serialized form. Preserved during parsing to ensure round-trip re-serialization. + serialized: String, + /// Parsed form. + dt: chrono::DateTime, +} + +impl PartialEq for Datetime { + fn eq(&self, other: &Self) -> bool { + self.dt == other.dt + } +} + +impl Ord for Datetime { + fn cmp(&self, other: &Self) -> cmp::Ordering { + self.dt.cmp(&other.dt) + } +} + +impl PartialOrd for Datetime { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Datetime { + /// Returns a `Datetime` which corresponds to the current date and time in UTC. + /// + /// The timestamp uses microsecond precision. + pub fn now() -> Self { + Self::new(chrono::Utc::now().fixed_offset()) + } + + /// Constructs a new Lexicon timestamp. + /// + /// The timestamp is rounded to microsecond precision. + pub fn new(dt: chrono::DateTime) -> Self { + let dt = dt + .duration_round(chrono::Duration::microseconds(1)) + .expect("delta does not exceed limits"); + // This serialization format is compatible with ISO 8601. + let serialized = dt.to_rfc3339_opts(chrono::SecondsFormat::Micros, true); + Self { serialized, dt } + } +} + +impl FromStr for Datetime { + type Err = chrono::ParseError; + + fn from_str(s: &str) -> Result { + // The `chrono` crate only supports RFC 3339 parsing, but Lexicon restricts + // datetimes to the subset that is also valid under ISO 8601. Apply a regex that + // validates enough of the relevant ISO 8601 format that the RFC 3339 parser can + // do the rest. + const RE_ISO_8601: OnceCell = OnceCell::new(); + if RE_ISO_8601 + .get_or_init(|| Regex::new(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|(\+[0-9]{2}|\-[0-9][1-9]):[0-9]{2})$").unwrap()) + .is_match(&s) + { + let dt = chrono::DateTime::parse_from_rfc3339(s)?; + Ok(Self { + serialized: s.into(), + dt, + }) + } else { + // Simulate an invalid `ParseError`. + Err(chrono::DateTime::parse_from_rfc3339("invalid").expect_err("invalid")) + } + } +} + +impl<'de> Deserialize<'de> for Datetime { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Deserialize::deserialize(deserializer)?; + Self::from_str(value).map_err(D::Error::custom) + } +} + +impl Serialize for Datetime { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.serialized) + } +} + +impl AsRef> for Datetime { + fn as_ref(&self) -> &chrono::DateTime { + &self.dt + } +} + /// A generic [DID Identifier]. /// /// [DID Identifier]: https://atproto.com/specs/did @@ -170,12 +270,159 @@ impl Handle { } } +/// A [Namespaced Identifier]. +/// +/// [Namespaced Identifier]: https://atproto.com/specs/nsid +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct Nsid(String); +string_newtype!(Nsid); + +impl Nsid { + /// Parses an NSID from the given string. + pub fn new(nsid: String) -> Result { + const RE_NSID: OnceCell = OnceCell::new(); + + // https://atproto.com/specs/handle#handle-identifier-syntax + if nsid.len() > 317 { + Err("NSID too long") + } else if !RE_NSID + .get_or_init(|| Regex::new(r"^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?)$").unwrap()) + .is_match(&nsid) + { + Err("Invalid NSID") + } else { + Ok(Self(nsid)) + } + } + + /// Returns the domain authority part of the NSID. + pub fn domain_authority(&self) -> &str { + let split = self.0.rfind('.').expect("enforced by constructor"); + &self.0[..split] + } + + /// Returns the name segment of the NSID. + pub fn name(&self) -> &str { + let split = self.0.rfind('.').expect("enforced by constructor"); + &self.0[split + 1..] + } + + /// Returns the NSID as a string slice. + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +/// An [IETF Language Tag] string. +/// +/// [IETF Language Tag]: https://en.wikipedia.org/wiki/IETF_language_tag +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(transparent)] +pub struct Language(LanguageTagBuf); + +impl Language { + /// Creates a new language tag by parsing the given string. + pub fn new(s: String) -> Result { + LanguageTagBuf::new(s.into()).map(Self).map_err(|(e, _)| e) + } + + /// Returns a [`LanguageTag`] referencing this tag. + #[inline] + pub fn as_ref(&self) -> LanguageTag { + self.0.as_ref() + } +} + +impl FromStr for Language { + type Err = langtag::Error; + + fn from_str(s: &str) -> Result { + Self::new(s.into()) + } +} + +impl Serialize for Language { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.0.as_str()) + } +} + #[cfg(test)] mod tests { - use serde_json::from_str; + use serde_json::{from_str, to_string}; use super::*; + #[test] + fn valid_datetime() { + // From https://atproto.com/specs/lexicon#datetime + for valid in &[ + // preferred + "1985-04-12T23:20:50.123Z", + "1985-04-12T23:20:50.123456Z", + "1985-04-12T23:20:50.120Z", + "1985-04-12T23:20:50.120000Z", + // supported + "1985-04-12T23:20:50.12345678912345Z", + "1985-04-12T23:20:50Z", + "1985-04-12T23:20:50.0Z", + "1985-04-12T23:20:50.123+00:00", + "1985-04-12T23:20:50.123-07:00", + ] { + let json_valid = format!("\"{}\"", valid); + let res = from_str::(&json_valid); + assert!(res.is_ok(), "valid Datetime `{}` parsed as invalid", valid); + let dt = res.unwrap(); + assert_eq!(to_string(&dt).unwrap(), json_valid); + } + } + + #[test] + fn invalid_datetime() { + // From https://atproto.com/specs/lexicon#datetime + for invalid in &[ + "1985-04-12", + "1985-04-12T23:20Z", + "1985-04-12T23:20:5Z", + "1985-04-12T23:20:50.123", + "+001985-04-12T23:20:50.123Z", + "23:20:50.123Z", + "-1985-04-12T23:20:50.123Z", + "1985-4-12T23:20:50.123Z", + "01985-04-12T23:20:50.123Z", + "1985-04-12T23:20:50.123+00", + "1985-04-12T23:20:50.123+0000", + // ISO-8601 strict capitalization + "1985-04-12t23:20:50.123Z", + "1985-04-12T23:20:50.123z", + // RFC-3339, but not ISO-8601 + "1985-04-12T23:20:50.123-00:00", + "1985-04-12 23:20:50.123Z", + // timezone is required + "1985-04-12T23:20:50.123", + // syntax looks ok, but datetime is not valid + "1985-04-12T23:99:50.123Z", + "1985-00-12T23:20:50.123Z", + ] { + assert!( + from_str::(&format!("\"{}\"", invalid)).is_err(), + "invalid Datetime `{}` parsed as valid", + invalid, + ); + } + } + + #[test] + fn datetime_round_trip() { + let dt = Datetime::now(); + let encoded = to_string(&dt).unwrap(); + assert_eq!(from_str::(&encoded).unwrap(), dt); + } + #[test] fn valid_did() { // From https://atproto.com/specs/did#examples @@ -282,4 +529,126 @@ mod tests { ); } } + + #[test] + fn valid_nsid() { + // From https://atproto.com/specs/nsid#examples + for valid in &[ + "com.example.fooBar", + "net.users.bob.ping", + "a-0.b-1.c", + "a.b.c", + "cn.8.lex.stuff", + ] { + assert!( + from_str::(&format!("\"{}\"", valid)).is_ok(), + "valid NSID `{}` parsed as invalid", + valid, + ); + } + } + + #[test] + fn invalid_nsid() { + // From https://atproto.com/specs/nsid#examples + for invalid in &["com.exa💩ple.thing", "com.example"] { + assert!( + from_str::(&format!("\"{}\"", invalid)).is_err(), + "invalid NSID `{}` parsed as valid", + invalid, + ); + } + } + + #[test] + fn nsid_parts() { + // From https://atproto.com/specs/nsid#examples + for (nsid, domain_authority, name) in &[ + ("com.example.fooBar", "com.example", "fooBar"), + ("net.users.bob.ping", "net.users.bob", "ping"), + ("a-0.b-1.c", "a-0.b-1", "c"), + ("a.b.c", "a.b", "c"), + ("cn.8.lex.stuff", "cn.8.lex", "stuff"), + ] { + let nsid = Nsid::new(nsid.to_string()).unwrap(); + assert_eq!(nsid.domain_authority(), *domain_authority); + assert_eq!(nsid.name(), *name); + } + } + + #[test] + fn valid_language() { + // From https://www.rfc-editor.org/rfc/rfc5646.html#appendix-A + for valid in &[ + // Simple language subtag: + "de", // German + "fr", // French + "ja", // Japanese + "i-enochian", // example of a grandfathered tag + // Language subtag plus Script subtag: + "zh-Hant", // Chinese written using the Traditional Chinese script + "zh-Hans", // Chinese written using the Simplified Chinese script + "sr-Cyrl", // Serbian written using the Cyrillic script + "sr-Latn", // Serbian written using the Latin script + // Extended language subtags and their primary language subtag counterparts: + "zh-cmn-Hans-CN", // Chinese, Mandarin, Simplified script, as used in China + "cmn-Hans-CN", // Mandarin Chinese, Simplified script, as used in China + "zh-yue-HK", // Chinese, Cantonese, as used in Hong Kong SAR + "yue-HK", // Cantonese Chinese, as used in Hong Kong SAR + // Language-Script-Region: + "zh-Hans-CN", // Chinese written using the Simplified script as used in mainland China + "sr-Latn-RS", // Serbian written using the Latin script as used in Serbia + // Language-Variant: + "sl-rozaj", // Resian dialect of Slovenian + "sl-rozaj-biske", // San Giorgio dialect of Resian dialect of Slovenian + "sl-nedis", // Nadiza dialect of Slovenian + // Language-Region-Variant: + "de-CH-1901", // German as used in Switzerland using the 1901 variant orthography + "sl-IT-nedis", // Slovenian as used in Italy, Nadiza dialect + // Language-Script-Region-Variant: + "hy-Latn-IT-arevela", // Eastern Armenian written in Latin script, as used in Italy + // Language-Region: + "de-DE", // German for Germany + "en-US", // English as used in the United States + "es-419", // Spanish appropriate for the Latin America and Caribbean region using the UN region code + // Private use subtags: + "de-CH-x-phonebk", + "az-Arab-x-AZE-derbend", + // Private use registry values: + "x-whatever", // private use using the singleton 'x' + "qaa-Qaaa-QM-x-southern", // all private tags + "de-Qaaa", // German, with a private script + "sr-Latn-QM", // Serbian, Latin script, private region + "sr-Qaaa-RS", // Serbian, private script, for Serbia + // Tags that use extensions (examples ONLY -- extensions MUST be defined by RFC): + "en-US-u-islamcal", + "zh-CN-a-myext-x-private", + "en-a-myext-b-another", + // Invalid tags that are well-formed: + "ar-a-aaa-b-bbb-a-ccc", // two extensions with same single-letter prefix + ] { + let json_valid = format!("\"{}\"", valid); + let res = from_str::(&json_valid); + assert!(res.is_ok(), "valid language `{}` parsed as invalid", valid); + let dt = res.unwrap(); + assert_eq!(to_string(&dt).unwrap(), json_valid); + } + } + + #[test] + fn invalid_language() { + // From https://www.rfc-editor.org/rfc/rfc5646.html#appendix-A + for invalid in &[ + "de-419-DE", // two region tags + // use of a single-character subtag in primary position; note that there are a + // few grandfathered tags that start with "i-" that are valid + "a-DE", + ] { + assert!( + from_str::(&format!("\"{}\"", invalid)).is_err(), + "invalid language `{}` parsed as valid", + invalid, + ); + } + } } diff --git a/atrium-cli/src/runner.rs b/atrium-cli/src/runner.rs index 33c17eb7..603ad9c5 100644 --- a/atrium-cli/src/runner.rs +++ b/atrium-cli/src/runner.rs @@ -1,10 +1,9 @@ use crate::commands::Command; use crate::store::SimpleJsonFileSessionStore; use atrium_api::agent::{store::SessionStore, AtpAgent}; -use atrium_api::types::string::{AtIdentifier, Handle}; +use atrium_api::types::string::{AtIdentifier, Datetime, Handle}; use atrium_api::xrpc::error::{Error, XrpcErrorKind}; use atrium_xrpc_client::reqwest::ReqwestClient; -use chrono::Local; use serde::Serialize; use std::path::PathBuf; use tokio::fs; @@ -202,10 +201,10 @@ impl Runner { .atproto .repo .create_record(atrium_api::com::atproto::repo::create_record::Input { - collection: "app.bsky.feed.post".into(), + collection: "app.bsky.feed.post".parse().expect("valid"), record: atrium_api::records::Record::AppBskyFeedPost(Box::new( atrium_api::app::bsky::feed::post::Record { - created_at: Local::now().to_rfc3339(), + created_at: Datetime::now(), embed: None, entities: None, facets: None, @@ -233,7 +232,7 @@ impl Runner { .atproto .repo .delete_record(atrium_api::com::atproto::repo::delete_record::Input { - collection: "app.bsky.feed.post".into(), + collection: "app.bsky.feed.post".parse().expect("valid"), repo: self.handle.clone().unwrap().into(), rkey: args.uri.rkey, swap_commit: None, diff --git a/lexicon/atrium-codegen/src/token_stream.rs b/lexicon/atrium-codegen/src/token_stream.rs index 988daafc..63a9d954 100644 --- a/lexicon/atrium-codegen/src/token_stream.rs +++ b/lexicon/atrium-codegen/src/token_stream.rs @@ -491,8 +491,11 @@ fn string_type(string: &LexString) -> Result<(TokenStream, TokenStream)> { // TODO: enum? let typ = match string.format { Some(LexStringFormat::AtIdentifier) => quote!(crate::types::string::AtIdentifier), + Some(LexStringFormat::Datetime) => quote!(crate::types::string::Datetime), Some(LexStringFormat::Did) => quote!(crate::types::string::Did), Some(LexStringFormat::Handle) => quote!(crate::types::string::Handle), + Some(LexStringFormat::Nsid) => quote!(crate::types::string::Nsid), + Some(LexStringFormat::Language) => quote!(crate::types::string::Language), // TODO: other formats _ => quote!(String), };