diff --git a/codegen_plan.md b/codegen_plan.md index faadb61..2b999a0 100644 --- a/codegen_plan.md +++ b/codegen_plan.md @@ -129,7 +129,7 @@ Same as queries but with both `Input` and `Output` structs. **Tasks**: 1. Create `LexiconCorpus` struct - - `HashMap>` - NSID → doc + - `BTreeMap>` - NSID → doc - Methods: `load_from_dir()`, `get()`, `resolve_ref()` 2. Load all `.json` files from lexicon directory 3. Parse into `LexiconDoc` and insert into registry @@ -261,9 +261,9 @@ pub struct Post<'a> { // ... fields } -impl Collection for Post<'_> { +impl Collection for Post<'p> { const NSID: &'static str = "app.bsky.feed.post"; - type Record = Post<'static>; + type Record = Post<'p>; } ``` @@ -275,7 +275,7 @@ pub trait XrpcRequest<'x> { /// The NSID for this XRPC method const NSID: &'static str; - /// HTTP method (GET for queries, POST for procedures) + /// XRPC method (query/GET, procedure/POST) const METHOD: XrpcMethod; /// Input encoding (MIME type, e.g., "application/json") @@ -290,6 +290,8 @@ pub trait XrpcRequest<'x> { /// Response output type type Output: Deserialize<'x>; + + type Err: Error; } pub enum XrpcMethod { @@ -298,6 +300,8 @@ pub enum XrpcMethod { } ``` + + **Generated implementation:** ```rust pub struct GetAuthorFeedParams<'a> { @@ -319,6 +323,7 @@ impl XrpcRequest for GetAuthorFeedParams<'_> { type Params = Self; type Output = GetAuthorFeedOutput<'static>; + type Err = GetAuthorFeedError; } ``` @@ -333,6 +338,12 @@ impl XrpcRequest for GetAuthorFeedParams<'_> { - Also supports `dyn XrpcRequest` for dynamic dispatch if needed - Client code can be generic over `impl XrpcRequest` + +#### XRPC Errors +Lexicons contain information on the kind of errors they can return. +Trait contains an associated error type. Error enum with thiserror::Error and +miette:Diagnostic derives and appropriate content generated based on lexicon info. + ### Subscriptions WebSocket streams - defer for now. Will need separate trait with message types. diff --git a/crates/jacquard-common/src/into_static.rs b/crates/jacquard-common/src/into_static.rs index 0705666..537eb65 100644 --- a/crates/jacquard-common/src/into_static.rs +++ b/crates/jacquard-common/src/into_static.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -67,7 +68,24 @@ macro_rules! impl_into_static_passthru { } impl_into_static_passthru!( - String, u128, u64, u32, u16, u8, i128, i64, i32, i16, i8, bool, char, usize, isize, f32, f64 + String, + u128, + u64, + u32, + u16, + u8, + i128, + i64, + i32, + i16, + i8, + bool, + char, + usize, + isize, + f32, + f64, + crate::smol_str::SmolStr ); impl IntoStatic for Box { @@ -119,6 +137,21 @@ where } } +impl IntoStatic for BTreeMap +where + K: IntoStatic + Ord, + V: IntoStatic, + K::Output: Ord, +{ + type Output = BTreeMap; + + fn into_static(self) -> Self::Output { + self.into_iter() + .map(|(k, v)| (k.into_static(), v.into_static())) + .collect() + } +} + impl IntoStatic for HashSet where T::Output: Eq + Hash, diff --git a/crates/jacquard-common/src/types/value/convert.rs b/crates/jacquard-common/src/types/value/convert.rs index f33605f..d2a9016 100644 --- a/crates/jacquard-common/src/types/value/convert.rs +++ b/crates/jacquard-common/src/types/value/convert.rs @@ -1,17 +1,13 @@ -use core::{any::TypeId, fmt}; -use std::{borrow::ToOwned, boxed::Box, collections::BTreeMap, vec::Vec}; - -use crate::{ - CowStr, - types::{ - DataModelType, - cid::Cid, - string::AtprotoStr, - value::{Array, Data, Object}, - }, +use crate::types::{ + DataModelType, + cid::Cid, + string::AtprotoStr, + value::{Array, Data, Object}, }; use bytes::Bytes; +use core::{any::TypeId, fmt}; use smol_str::SmolStr; +use std::{borrow::ToOwned, boxed::Box, collections::BTreeMap, vec::Vec}; /// Error used for converting from and into [`crate::types::value::Data`]. #[derive(Clone, Debug)] diff --git a/crates/jacquard-lexicon/src/corpus.rs b/crates/jacquard-lexicon/src/corpus.rs new file mode 100644 index 0000000..fdedd2f --- /dev/null +++ b/crates/jacquard-lexicon/src/corpus.rs @@ -0,0 +1,162 @@ +use crate::lexicon::{LexiconDoc, LexUserType}; +use jacquard_common::{into_static::IntoStatic, smol_str::SmolStr}; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::Path; + +/// Registry of all loaded lexicons for reference resolution +#[derive(Debug, Clone)] +pub struct LexiconCorpus { + /// Map from NSID to lexicon document + docs: BTreeMap>, +} + +impl LexiconCorpus { + /// Create an empty corpus + pub fn new() -> Self { + Self { + docs: BTreeMap::new(), + } + } + + /// Load all lexicons from a directory + pub fn load_from_dir(path: impl AsRef) -> io::Result { + let mut corpus = Self::new(); + + let schemas = crate::fs::find_schemas(path.as_ref())?; + for schema_path in schemas { + let content = fs::read_to_string(schema_path.as_ref())?; + let doc: LexiconDoc = serde_json::from_str(&content).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to parse {}: {}", schema_path.as_ref().display(), e), + ) + })?; + + let nsid = SmolStr::from(doc.id.to_string()); + corpus.docs.insert(nsid, doc.into_static()); + } + + Ok(corpus) + } + + /// Get a lexicon document by NSID + pub fn get(&self, nsid: &str) -> Option<&LexiconDoc<'static>> { + self.docs.get(nsid) + } + + /// Resolve a reference, handling fragments + /// + /// Examples: + /// - `app.bsky.feed.post` → main def from that lexicon + /// - `app.bsky.feed.post#replyRef` → replyRef def from that lexicon + pub fn resolve_ref(&self, ref_str: &str) -> Option<(&LexiconDoc<'static>, &LexUserType<'static>)> { + let (nsid, def_name) = if let Some((nsid, fragment)) = ref_str.split_once('#') { + (nsid, fragment) + } else { + (ref_str, "main") + }; + + let doc = self.get(nsid)?; + let def = doc.defs.get(def_name)?; + Some((doc, def)) + } + + /// Check if a reference exists + pub fn ref_exists(&self, ref_str: &str) -> bool { + self.resolve_ref(ref_str).is_some() + } + + /// Iterate over all documents + pub fn iter(&self) -> impl Iterator)> { + self.docs.iter() + } + + /// Number of loaded lexicons + pub fn len(&self) -> usize { + self.docs.len() + } + + /// Check if corpus is empty + pub fn is_empty(&self) -> bool { + self.docs.is_empty() + } +} + +impl Default for LexiconCorpus { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lexicon::LexUserType; + + #[test] + fn test_empty_corpus() { + let corpus = LexiconCorpus::new(); + assert!(corpus.is_empty()); + assert_eq!(corpus.len(), 0); + } + + #[test] + fn test_load_real_lexicons() { + let corpus = LexiconCorpus::load_from_dir("tests/fixtures/lexicons") + .expect("failed to load lexicons"); + + assert!(!corpus.is_empty()); + assert_eq!(corpus.len(), 10); + + // Check that we loaded the expected lexicons + assert!(corpus.get("app.bsky.feed.post").is_some()); + assert!(corpus.get("app.bsky.feed.getAuthorFeed").is_some()); + assert!(corpus.get("app.bsky.richtext.facet").is_some()); + assert!(corpus.get("app.bsky.embed.images").is_some()); + assert!(corpus.get("com.atproto.repo.strongRef").is_some()); + assert!(corpus.get("com.atproto.label.defs").is_some()); + } + + #[test] + fn test_resolve_ref_without_fragment() { + let corpus = LexiconCorpus::load_from_dir("tests/fixtures/lexicons") + .expect("failed to load lexicons"); + + // Without fragment should resolve to main def + let (doc, def) = corpus + .resolve_ref("app.bsky.feed.post") + .expect("should resolve"); + assert_eq!(doc.id.as_ref(), "app.bsky.feed.post"); + assert!(matches!(def, LexUserType::Record(_))); + } + + #[test] + fn test_resolve_ref_with_fragment() { + let corpus = LexiconCorpus::load_from_dir("tests/fixtures/lexicons") + .expect("failed to load lexicons"); + + // With fragment should resolve to specific def + let (doc, def) = corpus + .resolve_ref("app.bsky.richtext.facet#mention") + .expect("should resolve"); + assert_eq!(doc.id.as_ref(), "app.bsky.richtext.facet"); + assert!(matches!(def, LexUserType::Object(_))); + } + + #[test] + fn test_ref_exists() { + let corpus = LexiconCorpus::load_from_dir("tests/fixtures/lexicons") + .expect("failed to load lexicons"); + + // Existing refs + assert!(corpus.ref_exists("app.bsky.feed.post")); + assert!(corpus.ref_exists("app.bsky.feed.post#main")); + assert!(corpus.ref_exists("app.bsky.richtext.facet#mention")); + + // Non-existing refs + assert!(!corpus.ref_exists("com.example.fake")); + assert!(!corpus.ref_exists("app.bsky.feed.post#nonexistent")); + } +} diff --git a/crates/jacquard-lexicon/src/lexicon.rs b/crates/jacquard-lexicon/src/lexicon.rs index 1f81c53..51f0d8a 100644 --- a/crates/jacquard-lexicon/src/lexicon.rs +++ b/crates/jacquard-lexicon/src/lexicon.rs @@ -2,7 +2,7 @@ // https://github.com/atrium-rs/atrium/blob/main/lexicon/atrium-lex/src/lexicon.rs // https://github.com/atrium-rs/atrium/blob/main/lexicon/atrium-lex/src/lib.rs -use jacquard_common::{CowStr, smol_str::SmolStr, types::blob::MimeType}; +use jacquard_common::{into_static::IntoStatic, smol_str::SmolStr, types::blob::MimeType, CowStr}; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_with::skip_serializing_none; @@ -404,6 +404,438 @@ pub enum LexUserType<'s> { Unknown(LexUnknown<'s>), } +// IntoStatic implementations for all lexicon types +// These enable converting borrowed lexicon docs to owned 'static versions + +macro_rules! impl_into_static_for_lex_struct { + ($($ty:ident),+ $(,)?) => { + $( + impl IntoStatic for $ty<'_> { + type Output = $ty<'static>; + + fn into_static(self) -> Self::Output { + let Self { + $(description,)? + ..$fields + } = self; + Self::Output { + $(description: description.into_static(),)? + ..$fields.into_static() + } + } + } + )+ + }; +} + +// Simpler approach: just clone and convert each field +impl IntoStatic for Lexicon { + type Output = Lexicon; + fn into_static(self) -> Self::Output { + self + } +} + +impl IntoStatic for LexStringFormat { + type Output = LexStringFormat; + fn into_static(self) -> Self::Output { + self + } +} + +impl IntoStatic for LexiconDoc<'_> { + type Output = LexiconDoc<'static>; + fn into_static(self) -> Self::Output { + LexiconDoc { + lexicon: self.lexicon, + id: self.id.into_static(), + revision: self.revision, + description: self.description.into_static(), + defs: self.defs.into_static(), + } + } +} + +impl IntoStatic for LexBoolean<'_> { + type Output = LexBoolean<'static>; + fn into_static(self) -> Self::Output { + LexBoolean { + description: self.description.into_static(), + default: self.default, + r#const: self.r#const, + } + } +} + +impl IntoStatic for LexInteger<'_> { + type Output = LexInteger<'static>; + fn into_static(self) -> Self::Output { + LexInteger { + description: self.description.into_static(), + default: self.default, + minimum: self.minimum, + maximum: self.maximum, + r#enum: self.r#enum, + r#const: self.r#const, + } + } +} + +impl IntoStatic for LexString<'_> { + type Output = LexString<'static>; + fn into_static(self) -> Self::Output { + LexString { + description: self.description.into_static(), + format: self.format, + default: self.default.into_static(), + min_length: self.min_length, + max_length: self.max_length, + min_graphemes: self.min_graphemes, + max_graphemes: self.max_graphemes, + r#enum: self.r#enum.into_static(), + r#const: self.r#const.into_static(), + known_values: self.known_values.into_static(), + } + } +} + +impl IntoStatic for LexUnknown<'_> { + type Output = LexUnknown<'static>; + fn into_static(self) -> Self::Output { + LexUnknown { + description: self.description.into_static(), + } + } +} + +impl IntoStatic for LexBytes<'_> { + type Output = LexBytes<'static>; + fn into_static(self) -> Self::Output { + LexBytes { + description: self.description.into_static(), + max_length: self.max_length, + min_length: self.min_length, + } + } +} + +impl IntoStatic for LexCidLink<'_> { + type Output = LexCidLink<'static>; + fn into_static(self) -> Self::Output { + LexCidLink { + description: self.description.into_static(), + } + } +} + +impl IntoStatic for LexRef<'_> { + type Output = LexRef<'static>; + fn into_static(self) -> Self::Output { + LexRef { + description: self.description.into_static(), + r#ref: self.r#ref.into_static(), + } + } +} + +impl IntoStatic for LexRefUnion<'_> { + type Output = LexRefUnion<'static>; + fn into_static(self) -> Self::Output { + LexRefUnion { + description: self.description.into_static(), + refs: self.refs.into_static(), + closed: self.closed, + } + } +} + +impl IntoStatic for LexBlob<'_> { + type Output = LexBlob<'static>; + fn into_static(self) -> Self::Output { + LexBlob { + description: self.description.into_static(), + accept: self.accept.into_static(), + max_size: self.max_size, + } + } +} + +impl IntoStatic for LexArrayItem<'_> { + type Output = LexArrayItem<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Boolean(x) => LexArrayItem::Boolean(x.into_static()), + Self::Integer(x) => LexArrayItem::Integer(x.into_static()), + Self::String(x) => LexArrayItem::String(x.into_static()), + Self::Unknown(x) => LexArrayItem::Unknown(x.into_static()), + Self::Bytes(x) => LexArrayItem::Bytes(x.into_static()), + Self::CidLink(x) => LexArrayItem::CidLink(x.into_static()), + Self::Blob(x) => LexArrayItem::Blob(x.into_static()), + Self::Ref(x) => LexArrayItem::Ref(x.into_static()), + Self::Union(x) => LexArrayItem::Union(x.into_static()), + } + } +} + +impl IntoStatic for LexArray<'_> { + type Output = LexArray<'static>; + fn into_static(self) -> Self::Output { + LexArray { + description: self.description.into_static(), + items: self.items.into_static(), + min_length: self.min_length, + max_length: self.max_length, + } + } +} + +impl IntoStatic for LexPrimitiveArrayItem<'_> { + type Output = LexPrimitiveArrayItem<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Boolean(x) => LexPrimitiveArrayItem::Boolean(x.into_static()), + Self::Integer(x) => LexPrimitiveArrayItem::Integer(x.into_static()), + Self::String(x) => LexPrimitiveArrayItem::String(x.into_static()), + Self::Unknown(x) => LexPrimitiveArrayItem::Unknown(x.into_static()), + } + } +} + +impl IntoStatic for LexPrimitiveArray<'_> { + type Output = LexPrimitiveArray<'static>; + fn into_static(self) -> Self::Output { + LexPrimitiveArray { + description: self.description.into_static(), + items: self.items.into_static(), + min_length: self.min_length, + max_length: self.max_length, + } + } +} + +impl IntoStatic for LexToken<'_> { + type Output = LexToken<'static>; + fn into_static(self) -> Self::Output { + LexToken { + description: self.description.into_static(), + } + } +} + +impl IntoStatic for LexObjectProperty<'_> { + type Output = LexObjectProperty<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Ref(x) => LexObjectProperty::Ref(x.into_static()), + Self::Union(x) => LexObjectProperty::Union(x.into_static()), + Self::Bytes(x) => LexObjectProperty::Bytes(x.into_static()), + Self::CidLink(x) => LexObjectProperty::CidLink(x.into_static()), + Self::Array(x) => LexObjectProperty::Array(x.into_static()), + Self::Blob(x) => LexObjectProperty::Blob(x.into_static()), + Self::Boolean(x) => LexObjectProperty::Boolean(x.into_static()), + Self::Integer(x) => LexObjectProperty::Integer(x.into_static()), + Self::String(x) => LexObjectProperty::String(x.into_static()), + Self::Unknown(x) => LexObjectProperty::Unknown(x.into_static()), + } + } +} + +impl IntoStatic for LexObject<'_> { + type Output = LexObject<'static>; + fn into_static(self) -> Self::Output { + LexObject { + description: self.description.into_static(), + required: self.required, + nullable: self.nullable, + properties: self.properties.into_static(), + } + } +} + +impl IntoStatic for LexXrpcParametersProperty<'_> { + type Output = LexXrpcParametersProperty<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Boolean(x) => LexXrpcParametersProperty::Boolean(x.into_static()), + Self::Integer(x) => LexXrpcParametersProperty::Integer(x.into_static()), + Self::String(x) => LexXrpcParametersProperty::String(x.into_static()), + Self::Unknown(x) => LexXrpcParametersProperty::Unknown(x.into_static()), + Self::Array(x) => LexXrpcParametersProperty::Array(x.into_static()), + } + } +} + +impl IntoStatic for LexXrpcParameters<'_> { + type Output = LexXrpcParameters<'static>; + fn into_static(self) -> Self::Output { + LexXrpcParameters { + description: self.description.into_static(), + required: self.required, + properties: self.properties.into_static(), + } + } +} + +impl IntoStatic for LexXrpcBodySchema<'_> { + type Output = LexXrpcBodySchema<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Ref(x) => LexXrpcBodySchema::Ref(x.into_static()), + Self::Union(x) => LexXrpcBodySchema::Union(x.into_static()), + Self::Object(x) => LexXrpcBodySchema::Object(x.into_static()), + } + } +} + +impl IntoStatic for LexXrpcBody<'_> { + type Output = LexXrpcBody<'static>; + fn into_static(self) -> Self::Output { + LexXrpcBody { + description: self.description.into_static(), + encoding: self.encoding.into_static(), + schema: self.schema.into_static(), + } + } +} + +impl IntoStatic for LexXrpcSubscriptionMessageSchema<'_> { + type Output = LexXrpcSubscriptionMessageSchema<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Ref(x) => LexXrpcSubscriptionMessageSchema::Ref(x.into_static()), + Self::Union(x) => LexXrpcSubscriptionMessageSchema::Union(x.into_static()), + Self::Object(x) => LexXrpcSubscriptionMessageSchema::Object(x.into_static()), + } + } +} + +impl IntoStatic for LexXrpcSubscriptionMessage<'_> { + type Output = LexXrpcSubscriptionMessage<'static>; + fn into_static(self) -> Self::Output { + LexXrpcSubscriptionMessage { + description: self.description.into_static(), + schema: self.schema.into_static(), + } + } +} + +impl IntoStatic for LexXrpcError<'_> { + type Output = LexXrpcError<'static>; + fn into_static(self) -> Self::Output { + LexXrpcError { + description: self.description.into_static(), + name: self.name.into_static(), + } + } +} + +impl IntoStatic for LexXrpcQueryParameter<'_> { + type Output = LexXrpcQueryParameter<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Params(x) => LexXrpcQueryParameter::Params(x.into_static()), + } + } +} + +impl IntoStatic for LexXrpcQuery<'_> { + type Output = LexXrpcQuery<'static>; + fn into_static(self) -> Self::Output { + LexXrpcQuery { + description: self.description.into_static(), + parameters: self.parameters.into_static(), + output: self.output.into_static(), + errors: self.errors.into_static(), + } + } +} + +impl IntoStatic for LexXrpcProcedureParameter<'_> { + type Output = LexXrpcProcedureParameter<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Params(x) => LexXrpcProcedureParameter::Params(x.into_static()), + } + } +} + +impl IntoStatic for LexXrpcProcedure<'_> { + type Output = LexXrpcProcedure<'static>; + fn into_static(self) -> Self::Output { + LexXrpcProcedure { + description: self.description.into_static(), + parameters: self.parameters.into_static(), + input: self.input.into_static(), + output: self.output.into_static(), + errors: self.errors.into_static(), + } + } +} + +impl IntoStatic for LexXrpcSubscriptionParameter<'_> { + type Output = LexXrpcSubscriptionParameter<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Params(x) => LexXrpcSubscriptionParameter::Params(x.into_static()), + } + } +} + +impl IntoStatic for LexXrpcSubscription<'_> { + type Output = LexXrpcSubscription<'static>; + fn into_static(self) -> Self::Output { + LexXrpcSubscription { + description: self.description.into_static(), + parameters: self.parameters.into_static(), + message: self.message.into_static(), + infos: self.infos.into_static(), + errors: self.errors.into_static(), + } + } +} + +impl IntoStatic for LexRecordRecord<'_> { + type Output = LexRecordRecord<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Object(x) => LexRecordRecord::Object(x.into_static()), + } + } +} + +impl IntoStatic for LexRecord<'_> { + type Output = LexRecord<'static>; + fn into_static(self) -> Self::Output { + LexRecord { + description: self.description.into_static(), + key: self.key.into_static(), + record: self.record.into_static(), + } + } +} + +impl IntoStatic for LexUserType<'_> { + type Output = LexUserType<'static>; + fn into_static(self) -> Self::Output { + match self { + Self::Record(x) => LexUserType::Record(x.into_static()), + Self::XrpcQuery(x) => LexUserType::XrpcQuery(x.into_static()), + Self::XrpcProcedure(x) => LexUserType::XrpcProcedure(x.into_static()), + Self::XrpcSubscription(x) => LexUserType::XrpcSubscription(x.into_static()), + Self::Blob(x) => LexUserType::Blob(x.into_static()), + Self::Array(x) => LexUserType::Array(x.into_static()), + Self::Token(x) => LexUserType::Token(x.into_static()), + Self::Object(x) => LexUserType::Object(x.into_static()), + Self::Boolean(x) => LexUserType::Boolean(x.into_static()), + Self::Integer(x) => LexUserType::Integer(x.into_static()), + Self::String(x) => LexUserType::String(x.into_static()), + Self::Bytes(x) => LexUserType::Bytes(x.into_static()), + Self::CidLink(x) => LexUserType::CidLink(x.into_static()), + Self::Unknown(x) => LexUserType::Unknown(x.into_static()), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/jacquard-lexicon/src/lib.rs b/crates/jacquard-lexicon/src/lib.rs index 89cfc9f..a994b0d 100644 --- a/crates/jacquard-lexicon/src/lib.rs +++ b/crates/jacquard-lexicon/src/lib.rs @@ -1,3 +1,4 @@ +pub mod corpus; pub mod fs; pub mod lexicon; pub mod output; diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/defs.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/defs.json new file mode 100644 index 0000000..6f4c1ab --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/defs.json @@ -0,0 +1,156 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "label": { + "type": "object", + "description": "Metadata tag on an atproto resource (eg, repo or record).", + "required": ["src", "uri", "val", "cts"], + "properties": { + "ver": { + "type": "integer", + "description": "The AT Protocol version of the label object." + }, + "src": { + "type": "string", + "format": "did", + "description": "DID of the actor who created this label." + }, + "uri": { + "type": "string", + "format": "uri", + "description": "AT URI of the record, repository (account), or other resource that this label applies to." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." + }, + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + }, + "neg": { + "type": "boolean", + "description": "If true, this is a negation label, overwriting a previous label." + }, + "cts": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this label was created." + }, + "exp": { + "type": "string", + "format": "datetime", + "description": "Timestamp at which this label expires (no longer applies)." + }, + "sig": { + "type": "bytes", + "description": "Signature of dag-cbor encoded label." + } + } + }, + "selfLabels": { + "type": "object", + "description": "Metadata tags on an atproto record, published by the author within the record.", + "required": ["values"], + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + } + }, + "selfLabel": { + "type": "object", + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", + "required": ["val"], + "properties": { + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + } + } + }, + "labelValueDefinition": { + "type": "object", + "description": "Declares a label value and its expected interpretations and behaviors.", + "required": ["identifier", "severity", "blurs", "locales"], + "properties": { + "identifier": { + "type": "string", + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", + "maxLength": 100, + "maxGraphemes": 100 + }, + "severity": { + "type": "string", + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", + "knownValues": ["inform", "alert", "none"] + }, + "blurs": { + "type": "string", + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", + "knownValues": ["content", "media", "none"] + }, + "defaultSetting": { + "type": "string", + "description": "The default setting for this label.", + "knownValues": ["ignore", "warn", "hide"], + "default": "warn" + }, + "adultOnly": { + "type": "boolean", + "description": "Does the user need to have adult content enabled in order to configure this label?" + }, + "locales": { + "type": "array", + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } + } + } + }, + "labelValueDefinitionStrings": { + "type": "object", + "description": "Strings which describe the label in the UI, localized into a specific language.", + "required": ["lang", "name", "description"], + "properties": { + "lang": { + "type": "string", + "description": "The code of the language these strings are written in.", + "format": "language" + }, + "name": { + "type": "string", + "description": "A short human-readable name for the label.", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "description": "A longer description of what the label means and why it might be applied.", + "maxGraphemes": 10000, + "maxLength": 100000 + } + } + }, + "labelValue": { + "type": "string", + "knownValues": [ + "!hide", + "!no-promote", + "!warn", + "!no-unauthenticated", + "dmca-violation", + "doxxing", + "porn", + "sexual", + "nudity", + "nsfl", + "gore" + ] + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/external.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/external.json new file mode 100644 index 0000000..b9930df --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/external.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "app.bsky.embed.external", + "defs": { + "main": { + "type": "object", + "description": "A representation of some externally linked content (eg, a URL and 'card'), embedded in a Bluesky record (eg, a post).", + "required": ["external"], + "properties": { + "external": { + "type": "ref", + "ref": "#external" + } + } + }, + "external": { + "type": "object", + "required": ["uri", "title", "description"], + "properties": { + "uri": { "type": "string", "format": "uri" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "thumb": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000 + } + } + }, + "view": { + "type": "object", + "required": ["external"], + "properties": { + "external": { + "type": "ref", + "ref": "#viewExternal" + } + } + }, + "viewExternal": { + "type": "object", + "required": ["uri", "title", "description"], + "properties": { + "uri": { "type": "string", "format": "uri" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "thumb": { "type": "string", "format": "uri" } + } + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/facet.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/facet.json new file mode 100644 index 0000000..388a3a5 --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/facet.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "app.bsky.richtext.facet", + "defs": { + "main": { + "type": "object", + "description": "Annotation of a sub-string within rich text.", + "required": ["index", "features"], + "properties": { + "index": { "type": "ref", "ref": "#byteSlice" }, + "features": { + "type": "array", + "items": { "type": "union", "refs": ["#mention", "#link", "#tag"] } + } + } + }, + "mention": { + "type": "object", + "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" } + } + }, + "link": { + "type": "object", + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "uri" } + } + }, + "tag": { + "type": "object", + "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", + "required": ["tag"], + "properties": { + "tag": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } + } + }, + "byteSlice": { + "type": "object", + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", + "required": ["byteStart", "byteEnd"], + "properties": { + "byteStart": { "type": "integer", "minimum": 0 }, + "byteEnd": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/getAuthorFeed.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/getAuthorFeed.json new file mode 100644 index 0000000..c9ef8c5 --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/getAuthorFeed.json @@ -0,0 +1,58 @@ +{ + "lexicon": 1, + "id": "app.bsky.feed.getAuthorFeed", + "defs": { + "main": { + "type": "query", + "description": "Get a view of an actor's 'author feed' (post and reposts by the author). Does not require auth.", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { "type": "string", "format": "at-identifier" }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { "type": "string" }, + "filter": { + "type": "string", + "description": "Combinations of post/repost types to include in response.", + "knownValues": [ + "posts_with_replies", + "posts_no_replies", + "posts_with_media", + "posts_and_author_threads", + "posts_with_video" + ], + "default": "posts_with_replies" + }, + "includePins": { + "type": "boolean", + "default": false + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["feed"], + "properties": { + "cursor": { "type": "string" }, + "feed": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.feed.defs#feedViewPost" + } + } + } + } + }, + "errors": [{ "name": "BlockedActor" }, { "name": "BlockedByActor" }] + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/images.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/images.json new file mode 100644 index 0000000..c6b6fa2 --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/images.json @@ -0,0 +1,72 @@ +{ + "lexicon": 1, + "id": "app.bsky.embed.images", + "description": "A set of images embedded in a Bluesky record (eg, a post).", + "defs": { + "main": { + "type": "object", + "required": ["images"], + "properties": { + "images": { + "type": "array", + "items": { "type": "ref", "ref": "#image" }, + "maxLength": 4 + } + } + }, + "image": { + "type": "object", + "required": ["image", "alt"], + "properties": { + "image": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000 + }, + "alt": { + "type": "string", + "description": "Alt text description of the image, for accessibility." + }, + "aspectRatio": { + "type": "ref", + "ref": "app.bsky.embed.defs#aspectRatio" + } + } + }, + "view": { + "type": "object", + "required": ["images"], + "properties": { + "images": { + "type": "array", + "items": { "type": "ref", "ref": "#viewImage" }, + "maxLength": 4 + } + } + }, + "viewImage": { + "type": "object", + "required": ["thumb", "fullsize", "alt"], + "properties": { + "thumb": { + "type": "string", + "format": "uri", + "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." + }, + "fullsize": { + "type": "string", + "format": "uri", + "description": "Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View." + }, + "alt": { + "type": "string", + "description": "Alt text description of the image, for accessibility." + }, + "aspectRatio": { + "type": "ref", + "ref": "app.bsky.embed.defs#aspectRatio" + } + } + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/post.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/post.json new file mode 100644 index 0000000..9893a35 --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/post.json @@ -0,0 +1,96 @@ +{ + "lexicon": 1, + "id": "app.bsky.feed.post", + "defs": { + "main": { + "type": "record", + "description": "Record containing a Bluesky post.", + "key": "tid", + "record": { + "type": "object", + "required": ["text", "createdAt"], + "properties": { + "text": { + "type": "string", + "maxLength": 3000, + "maxGraphemes": 300, + "description": "The primary post content. May be an empty string, if there are embeds." + }, + "entities": { + "type": "array", + "description": "DEPRECATED: replaced by app.bsky.richtext.facet.", + "items": { "type": "ref", "ref": "#entity" } + }, + "facets": { + "type": "array", + "description": "Annotations of text (mentions, URLs, hashtags, etc)", + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } + }, + "reply": { "type": "ref", "ref": "#replyRef" }, + "embed": { + "type": "union", + "refs": [ + "app.bsky.embed.images", + "app.bsky.embed.video", + "app.bsky.embed.external", + "app.bsky.embed.record", + "app.bsky.embed.recordWithMedia" + ] + }, + "langs": { + "type": "array", + "description": "Indicates human language of post primary text content.", + "maxLength": 3, + "items": { "type": "string", "format": "language" } + }, + "labels": { + "type": "union", + "description": "Self-label values for this post. Effectively content warnings.", + "refs": ["com.atproto.label.defs#selfLabels"] + }, + "tags": { + "type": "array", + "description": "Additional hashtags, in addition to any included in post text and facets.", + "maxLength": 8, + "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this post was originally created." + } + } + } + }, + "replyRef": { + "type": "object", + "required": ["root", "parent"], + "properties": { + "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, + "parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" } + } + }, + "entity": { + "type": "object", + "description": "Deprecated: use facets instead.", + "required": ["index", "type", "value"], + "properties": { + "index": { "type": "ref", "ref": "#textSlice" }, + "type": { + "type": "string", + "description": "Expected values are 'mention' and 'link'." + }, + "value": { "type": "string" } + } + }, + "textSlice": { + "type": "object", + "description": "Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings.", + "required": ["start", "end"], + "properties": { + "start": { "type": "integer", "minimum": 0 }, + "end": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/record.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/record.json new file mode 100644 index 0000000..a73200a --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/record.json @@ -0,0 +1,96 @@ +{ + "lexicon": 1, + "id": "app.bsky.embed.record", + "description": "A representation of a record embedded in a Bluesky record (eg, a post). For example, a quote-post, or sharing a feed generator record.", + "defs": { + "main": { + "type": "object", + "required": ["record"], + "properties": { + "record": { "type": "ref", "ref": "com.atproto.repo.strongRef" } + } + }, + "view": { + "type": "object", + "required": ["record"], + "properties": { + "record": { + "type": "union", + "refs": [ + "#viewRecord", + "#viewNotFound", + "#viewBlocked", + "#viewDetached", + "app.bsky.feed.defs#generatorView", + "app.bsky.graph.defs#listView", + "app.bsky.labeler.defs#labelerView", + "app.bsky.graph.defs#starterPackViewBasic" + ] + } + } + }, + "viewRecord": { + "type": "object", + "required": ["uri", "cid", "author", "value", "indexedAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "author": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileViewBasic" + }, + "value": { + "type": "unknown", + "description": "The record data itself." + }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "replyCount": { "type": "integer" }, + "repostCount": { "type": "integer" }, + "likeCount": { "type": "integer" }, + "quoteCount": { "type": "integer" }, + "embeds": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "app.bsky.embed.images#view", + "app.bsky.embed.video#view", + "app.bsky.embed.external#view", + "app.bsky.embed.record#view", + "app.bsky.embed.recordWithMedia#view" + ] + } + }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "viewNotFound": { + "type": "object", + "required": ["uri", "notFound"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "notFound": { "type": "boolean", "const": true } + } + }, + "viewBlocked": { + "type": "object", + "required": ["uri", "blocked", "author"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "blocked": { "type": "boolean", "const": true }, + "author": { "type": "ref", "ref": "app.bsky.feed.defs#blockedAuthor" } + } + }, + "viewDetached": { + "type": "object", + "required": ["uri", "detached"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "detached": { "type": "boolean", "const": true } + } + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/recordWithMedia.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/recordWithMedia.json new file mode 100644 index 0000000..5baf685 --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/recordWithMedia.json @@ -0,0 +1,43 @@ +{ + "lexicon": 1, + "id": "app.bsky.embed.recordWithMedia", + "description": "A representation of a record embedded in a Bluesky record (eg, a post), alongside other compatible embeds. For example, a quote post and image, or a quote post and external URL card.", + "defs": { + "main": { + "type": "object", + "required": ["record", "media"], + "properties": { + "record": { + "type": "ref", + "ref": "app.bsky.embed.record" + }, + "media": { + "type": "union", + "refs": [ + "app.bsky.embed.images", + "app.bsky.embed.video", + "app.bsky.embed.external" + ] + } + } + }, + "view": { + "type": "object", + "required": ["record", "media"], + "properties": { + "record": { + "type": "ref", + "ref": "app.bsky.embed.record#view" + }, + "media": { + "type": "union", + "refs": [ + "app.bsky.embed.images#view", + "app.bsky.embed.video#view", + "app.bsky.embed.external#view" + ] + } + } + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/strongRef.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/strongRef.json new file mode 100644 index 0000000..cb79625 --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/strongRef.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.strongRef", + "description": "A URI with a content-hash fingerprint.", + "defs": { + "main": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" } + } + } + } +} diff --git a/crates/jacquard-lexicon/tests/fixtures/lexicons/video.json b/crates/jacquard-lexicon/tests/fixtures/lexicons/video.json new file mode 100644 index 0000000..92511af --- /dev/null +++ b/crates/jacquard-lexicon/tests/fixtures/lexicons/video.json @@ -0,0 +1,67 @@ +{ + "lexicon": 1, + "id": "app.bsky.embed.video", + "description": "A video embedded in a Bluesky record (eg, a post).", + "defs": { + "main": { + "type": "object", + "required": ["video"], + "properties": { + "video": { + "type": "blob", + "description": "The mp4 video file. May be up to 100mb, formerly limited to 50mb.", + "accept": ["video/mp4"], + "maxSize": 100000000 + }, + "captions": { + "type": "array", + "items": { "type": "ref", "ref": "#caption" }, + "maxLength": 20 + }, + "alt": { + "type": "string", + "description": "Alt text description of the video, for accessibility.", + "maxGraphemes": 1000, + "maxLength": 10000 + }, + "aspectRatio": { + "type": "ref", + "ref": "app.bsky.embed.defs#aspectRatio" + } + } + }, + "caption": { + "type": "object", + "required": ["lang", "file"], + "properties": { + "lang": { + "type": "string", + "format": "language" + }, + "file": { + "type": "blob", + "accept": ["text/vtt"], + "maxSize": 20000 + } + } + }, + "view": { + "type": "object", + "required": ["cid", "playlist"], + "properties": { + "cid": { "type": "string", "format": "cid" }, + "playlist": { "type": "string", "format": "uri" }, + "thumbnail": { "type": "string", "format": "uri" }, + "alt": { + "type": "string", + "maxGraphemes": 1000, + "maxLength": 10000 + }, + "aspectRatio": { + "type": "ref", + "ref": "app.bsky.embed.defs#aspectRatio" + } + } + } + } +}