mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-27 19:36:49 +00:00
feat(lexicon): add crate with schema types and format validators
This commit is contained in:
Generated
+19
@@ -6212,6 +6212,24 @@ dependencies = [
|
||||
"tranquil-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hickory-resolver",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"urlencoding",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.3.1"
|
||||
@@ -6309,6 +6327,7 @@ dependencies = [
|
||||
"tranquil-crypto",
|
||||
"tranquil-db",
|
||||
"tranquil-db-traits",
|
||||
"tranquil-lexicon",
|
||||
"tranquil-oauth",
|
||||
"tranquil-repo",
|
||||
"tranquil-ripple",
|
||||
|
||||
@@ -16,6 +16,7 @@ members = [
|
||||
"crates/tranquil-db-traits",
|
||||
"crates/tranquil-db",
|
||||
"crates/tranquil-pds",
|
||||
"crates/tranquil-lexicon",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -38,6 +39,9 @@ tranquil-comms = { path = "crates/tranquil-comms" }
|
||||
tranquil-db-traits = { path = "crates/tranquil-db-traits" }
|
||||
tranquil-db = { path = "crates/tranquil-db" }
|
||||
tranquil-ripple = { path = "crates/tranquil-ripple" }
|
||||
tranquil-lexicon = { path = "crates/tranquil-lexicon" }
|
||||
|
||||
unicode-segmentation = "1"
|
||||
|
||||
aes-gcm = "0.10"
|
||||
backon = "1"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "tranquil-lexicon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
hickory-resolver = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
parking_lot = { workspace = true, optional = true }
|
||||
tracing = { workspace = true, optional = true }
|
||||
urlencoding = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -0,0 +1,217 @@
|
||||
pub fn is_valid_did(s: &str) -> bool {
|
||||
s.strip_prefix("did:")
|
||||
.and_then(|rest| rest.split_once(':'))
|
||||
.is_some_and(|(method, id)| {
|
||||
!method.is_empty()
|
||||
&& method
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& !id.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_handle(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 253
|
||||
&& s.contains('.')
|
||||
&& s.split('.').all(|seg| {
|
||||
!seg.is_empty()
|
||||
&& seg.len() <= 63
|
||||
&& seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
&& !seg.starts_with('-')
|
||||
&& !seg.ends_with('-')
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_at_uri(s: &str) -> bool {
|
||||
s.strip_prefix("at://").is_some_and(|rest| {
|
||||
let authority = rest.split('/').next().unwrap_or("");
|
||||
is_valid_did(authority) || is_valid_handle(authority)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_datetime(s: &str) -> bool {
|
||||
chrono::DateTime::parse_from_rfc3339(s).is_ok()
|
||||
}
|
||||
|
||||
pub fn is_valid_uri(s: &str) -> bool {
|
||||
s.split_once("://").is_some_and(|(scheme, rest)| {
|
||||
!scheme.is_empty()
|
||||
&& scheme
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
|
||||
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|
||||
&& !rest.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_cid(s: &str) -> bool {
|
||||
s.len() >= 8
|
||||
&& s.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
&& s.starts_with(|c: char| c == 'b' || c == 'z' || c == 'Q')
|
||||
}
|
||||
|
||||
pub fn is_valid_language(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
}
|
||||
|
||||
pub fn is_valid_tid(s: &str) -> bool {
|
||||
s.len() == 13
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
}
|
||||
|
||||
pub fn is_valid_record_key(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 512
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
&& s.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~' || c == ':'
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_valid_at_identifier(s: &str) -> bool {
|
||||
is_valid_did(s) || is_valid_handle(s)
|
||||
}
|
||||
|
||||
pub fn is_valid_nsid(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.split('.').count() >= 3
|
||||
&& s.split('.').all(|seg| {
|
||||
!seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
})
|
||||
}
|
||||
|
||||
use crate::schema::StringFormat;
|
||||
|
||||
pub fn validate_format(format: &StringFormat, value: &str) -> bool {
|
||||
match format {
|
||||
StringFormat::Did => is_valid_did(value),
|
||||
StringFormat::Handle => is_valid_handle(value),
|
||||
StringFormat::AtUri => is_valid_at_uri(value),
|
||||
StringFormat::Datetime => is_valid_datetime(value),
|
||||
StringFormat::Uri => is_valid_uri(value),
|
||||
StringFormat::Cid => is_valid_cid(value),
|
||||
StringFormat::Language => is_valid_language(value),
|
||||
StringFormat::Tid => is_valid_tid(value),
|
||||
StringFormat::RecordKey => is_valid_record_key(value),
|
||||
StringFormat::AtIdentifier => is_valid_at_identifier(value),
|
||||
StringFormat::Nsid => is_valid_nsid(value),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_dids() {
|
||||
assert!(is_valid_did("did:plc:1234567890abcdefghijk"));
|
||||
assert!(is_valid_did("did:web:example.com"));
|
||||
assert!(!is_valid_did(""));
|
||||
assert!(!is_valid_did("plc:123"));
|
||||
assert!(!is_valid_did("did:"));
|
||||
assert!(!is_valid_did("did:plc:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_handles() {
|
||||
assert!(is_valid_handle("user.bsky.social"));
|
||||
assert!(is_valid_handle("example.com"));
|
||||
assert!(!is_valid_handle("noperiod"));
|
||||
assert!(!is_valid_handle(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_at_uris() {
|
||||
assert!(is_valid_at_uri("at://did:plc:abc/app.bsky.feed.post/123"));
|
||||
assert!(is_valid_at_uri(
|
||||
"at://user.bsky.social/app.bsky.feed.post/123"
|
||||
));
|
||||
assert!(!is_valid_at_uri("https://example.com"));
|
||||
assert!(!is_valid_at_uri("at://"));
|
||||
assert!(!is_valid_at_uri("at://not valid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_datetimes() {
|
||||
assert!(is_valid_datetime("2024-01-01T00:00:00.000Z"));
|
||||
assert!(is_valid_datetime("2024-01-01T00:00:00Z"));
|
||||
assert!(!is_valid_datetime("not-a-date"));
|
||||
assert!(!is_valid_datetime("2024-13-01T00:00:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_uris() {
|
||||
assert!(is_valid_uri("https://example.com"));
|
||||
assert!(is_valid_uri("http://localhost"));
|
||||
assert!(is_valid_uri("ftp://files.example.com/path"));
|
||||
assert!(!is_valid_uri("://x"));
|
||||
assert!(!is_valid_uri("not a uri"));
|
||||
assert!(!is_valid_uri("123://bad"));
|
||||
assert!(!is_valid_uri("https://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_cids() {
|
||||
assert!(is_valid_cid("bafyreiabcdef123456"));
|
||||
assert!(is_valid_cid(
|
||||
"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"
|
||||
));
|
||||
assert!(is_valid_cid("zQmSomeMultibase"));
|
||||
assert!(!is_valid_cid("abc"));
|
||||
assert!(!is_valid_cid(""));
|
||||
assert!(!is_valid_cid("xyzinvalidprefix1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_tids() {
|
||||
assert!(is_valid_tid("3k2n5j2abcdef"));
|
||||
assert!(!is_valid_tid("short"));
|
||||
assert!(!is_valid_tid("3K2N5J2ABCDEF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_record_keys() {
|
||||
assert!(is_valid_record_key("valid-key_123"));
|
||||
assert!(is_valid_record_key("self"));
|
||||
assert!(!is_valid_record_key(""));
|
||||
assert!(!is_valid_record_key("."));
|
||||
assert!(!is_valid_record_key(".."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_nsids() {
|
||||
assert!(is_valid_nsid("app.bsky.feed.post"));
|
||||
assert!(is_valid_nsid("com.atproto.repo.strongRef"));
|
||||
assert!(!is_valid_nsid("too.short"));
|
||||
assert!(!is_valid_nsid(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_did_method_with_digits() {
|
||||
assert!(is_valid_did(
|
||||
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
||||
));
|
||||
assert!(is_valid_did("did:3:abc123"));
|
||||
assert!(is_valid_did("did:a1b2:test"));
|
||||
assert!(!is_valid_did("did:UPPER:test"));
|
||||
assert!(!is_valid_did("did::test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_key_with_colon() {
|
||||
assert!(is_valid_record_key("self"));
|
||||
assert!(is_valid_record_key("key:with:colons"));
|
||||
assert!(is_valid_record_key("at:something"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_languages() {
|
||||
assert!(is_valid_language("en"));
|
||||
assert!(is_valid_language("en-US"));
|
||||
assert!(is_valid_language("pt-BR"));
|
||||
assert!(!is_valid_language(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
mod formats;
|
||||
mod registry;
|
||||
mod schema;
|
||||
mod validate;
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
mod dynamic;
|
||||
#[cfg(feature = "resolve")]
|
||||
mod resolve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_schemas;
|
||||
|
||||
pub use formats::{
|
||||
is_valid_at_identifier, is_valid_at_uri, is_valid_cid, is_valid_datetime, is_valid_did,
|
||||
is_valid_handle, is_valid_language, is_valid_nsid, is_valid_record_key, is_valid_tid,
|
||||
is_valid_uri,
|
||||
};
|
||||
pub use registry::LexiconRegistry;
|
||||
pub use schema::{LexiconDoc, ParsedRef, parse_ref};
|
||||
pub use validate::{LexValidationError, validate_record};
|
||||
|
||||
#[cfg(feature = "resolve")]
|
||||
pub use resolve::{
|
||||
ResolveError, fetch_schema_from_pds, resolve_did_from_dns, resolve_lexicon,
|
||||
resolve_lexicon_from_did, resolve_lexicon_with_config, resolve_pds_endpoint,
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexiconDoc {
|
||||
pub lexicon: u32,
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub defs: HashMap<String, LexDef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum LexDef {
|
||||
#[serde(rename = "record")]
|
||||
Record(LexRecord),
|
||||
#[serde(rename = "object")]
|
||||
Object(LexObject),
|
||||
#[serde(rename = "token")]
|
||||
Token {},
|
||||
#[serde(rename = "string")]
|
||||
StringDef(LexStringDef),
|
||||
#[serde(rename = "query")]
|
||||
Query {},
|
||||
#[serde(rename = "procedure")]
|
||||
Procedure {},
|
||||
#[serde(rename = "subscription")]
|
||||
Subscription {},
|
||||
#[serde(rename = "params")]
|
||||
Params {},
|
||||
#[serde(rename = "permission")]
|
||||
Permission {},
|
||||
#[serde(rename = "permission-set")]
|
||||
PermissionSet {},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexRecord {
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
pub record: LexObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexObject {
|
||||
#[serde(default)]
|
||||
pub required: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub nullable: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, LexProperty>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum LexProperty {
|
||||
#[serde(rename = "string")]
|
||||
String(LexString),
|
||||
#[serde(rename = "integer")]
|
||||
Integer(LexInteger),
|
||||
#[serde(rename = "boolean")]
|
||||
Boolean {},
|
||||
#[serde(rename = "bytes")]
|
||||
Bytes(LexBytes),
|
||||
#[serde(rename = "cid-link")]
|
||||
CidLink {},
|
||||
#[serde(rename = "blob")]
|
||||
Blob(LexBlob),
|
||||
#[serde(rename = "unknown")]
|
||||
Unknown {},
|
||||
#[serde(rename = "ref")]
|
||||
Ref(LexRef),
|
||||
#[serde(rename = "union")]
|
||||
Union(LexUnion),
|
||||
#[serde(rename = "array")]
|
||||
Array(LexArray),
|
||||
#[serde(rename = "object")]
|
||||
Object(LexObject),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexString {
|
||||
#[serde(default)]
|
||||
pub max_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub min_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_graphemes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub min_graphemes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub format: Option<StringFormat>,
|
||||
#[serde(default)]
|
||||
pub known_values: Option<Vec<String>>,
|
||||
#[serde(rename = "enum", default)]
|
||||
pub enum_values: Option<Vec<String>>,
|
||||
#[serde(rename = "const", default)]
|
||||
pub const_value: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexInteger {
|
||||
#[serde(default)]
|
||||
pub minimum: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub maximum: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub default: Option<i64>,
|
||||
#[serde(rename = "enum", default)]
|
||||
pub enum_values: Option<Vec<i64>>,
|
||||
#[serde(rename = "const", default)]
|
||||
pub const_value: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexBytes {
|
||||
#[serde(default)]
|
||||
pub max_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub min_length: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexBlob {
|
||||
#[serde(default)]
|
||||
pub accept: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub max_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexArray {
|
||||
pub items: Box<LexProperty>,
|
||||
#[serde(default)]
|
||||
pub min_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_length: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LexUnion {
|
||||
#[serde(default)]
|
||||
pub refs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexRef {
|
||||
#[serde(rename = "ref")]
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub enum StringFormat {
|
||||
#[serde(rename = "did")]
|
||||
Did,
|
||||
#[serde(rename = "handle")]
|
||||
Handle,
|
||||
#[serde(rename = "at-uri")]
|
||||
AtUri,
|
||||
#[serde(rename = "datetime")]
|
||||
Datetime,
|
||||
#[serde(rename = "uri")]
|
||||
Uri,
|
||||
#[serde(rename = "cid")]
|
||||
Cid,
|
||||
#[serde(rename = "language")]
|
||||
Language,
|
||||
#[serde(rename = "tid")]
|
||||
Tid,
|
||||
#[serde(rename = "record-key")]
|
||||
RecordKey,
|
||||
#[serde(rename = "at-identifier")]
|
||||
AtIdentifier,
|
||||
#[serde(rename = "nsid")]
|
||||
Nsid,
|
||||
}
|
||||
|
||||
pub enum ParsedRef<'a> {
|
||||
Local(&'a str),
|
||||
Qualified { nsid: &'a str, fragment: &'a str },
|
||||
Bare(&'a str),
|
||||
}
|
||||
|
||||
pub fn parse_ref(reference: &str) -> ParsedRef<'_> {
|
||||
match reference.strip_prefix('#') {
|
||||
Some(local) => ParsedRef::Local(local),
|
||||
None => {
|
||||
let stripped = reference.strip_prefix("lex:").unwrap_or(reference);
|
||||
match stripped.split_once('#') {
|
||||
Some((nsid, fragment)) => ParsedRef::Qualified { nsid, fragment },
|
||||
None => ParsedRef::Bare(stripped),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LexStringDef {}
|
||||
Reference in New Issue
Block a user