feat(lexicon): add crate with schema types and format validators

This commit is contained in:
Lewis
2026-03-13 19:53:18 +00:00
committed by Tangled
parent 559cbc1001
commit 33e96665b8
6 changed files with 501 additions and 0 deletions
Generated
+19
View File
@@ -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",
+4
View File
@@ -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"
+26
View File
@@ -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 }
+217
View File
@@ -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(""));
}
}
+27
View File
@@ -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,
};
+208
View File
@@ -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 {}