diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e6304b6..4b40ef648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### Added +**Value type deserialization** (`jacquard-common`) +- `from_json_value()`: Deserialize typed data directly from `serde_json::Value` without borrowing +- `from_data_owned()`, `from_raw_data_owned()`: Owned deserialization helpers +- `Data::from_json_owned()`: Parse JSON into owned `Data<'static>` +- `IntoStatic` implementation for `RawData` enabling owned conversions +- Re-exported value types from crate root for easier imports +- `Deserializer` trait implementations for `Data<'static>` and `RawData<'static>` +- Owned deserializer helpers: `OwnedArrayDeserializer`, `OwnedObjectDeserializer`, `OwnedBlobDeserializer` + **Service Auth** (`jacquard-axum`, `jacquard-common`) - Full service authentication implementation for inter-service JWT verification - `ExtractServiceAuth` Axum extractor for validating service auth tokens @@ -11,7 +20,6 @@ - Service auth claims validation (issuer, audience, expiration, method binding) - DID document resolution for signing key verification - Optional replay protection via `ReplayTracker` trait -- See CLAUDE.md for detailed implementation notes **XrpcRequest derive macro** (`jacquard-derive`) - `#[derive(XrpcRequest)]` for custom XRPC endpoints diff --git a/crates/jacquard-common/src/lib.rs b/crates/jacquard-common/src/lib.rs index caeeb6b58..df9455ced 100644 --- a/crates/jacquard-common/src/lib.rs +++ b/crates/jacquard-common/src/lib.rs @@ -221,6 +221,8 @@ pub mod types; // XRPC protocol types and traits pub mod xrpc; +pub use types::value::*; + /// Authorization token types for XRPC requests. #[derive(Debug, Clone)] pub enum AuthorizationToken<'s> { diff --git a/crates/jacquard-common/src/types/value.rs b/crates/jacquard-common/src/types/value.rs index d6923ce39..683b19d8a 100644 --- a/crates/jacquard-common/src/types/value.rs +++ b/crates/jacquard-common/src/types/value.rs @@ -112,6 +112,11 @@ impl<'s> Data<'s> { }) } + /// Parse a Data value from a JSON value (owned) + pub fn from_json_owned(json: serde_json::Value) -> Result, AtDataError> { + Data::from_json(&json).map(|data| data.into_static()) + } + /// Parse a Data value from an IPLD value (CBOR) pub fn from_cbor(cbor: &'s Ipld) -> Result { Ok(match cbor { @@ -352,6 +357,28 @@ pub enum RawData<'s> { InvalidData(Bytes), } +impl IntoStatic for RawData<'_> { + type Output = RawData<'static>; + + fn into_static(self) -> Self::Output { + match self { + RawData::Null => RawData::Null, + RawData::Boolean(b) => RawData::Boolean(b), + RawData::SignedInt(i) => RawData::SignedInt(i), + RawData::UnsignedInt(u) => RawData::UnsignedInt(u), + RawData::String(s) => RawData::String(s.into_static()), + RawData::Bytes(b) => RawData::Bytes(b.into_static()), + RawData::CidLink(c) => RawData::CidLink(c.into_static()), + RawData::Array(a) => RawData::Array(a.into_static()), + RawData::Object(o) => RawData::Object(o.into_static()), + RawData::Blob(b) => RawData::Blob(b.into_static()), + RawData::InvalidBlob(b) => RawData::InvalidBlob(b.into_static()), + RawData::InvalidNumber(b) => RawData::InvalidNumber(b.into_static()), + RawData::InvalidData(b) => RawData::InvalidData(b.into_static()), + } + } +} + /// Deserialize a typed value from a `Data` value /// /// Allows extracting strongly-typed structures from untyped `Data` values, @@ -384,6 +411,28 @@ where T::deserialize(data) } +/// Deserialize a typed value from a `Data` value +/// +/// Takes ownership rather than borrows. Will allocate. +pub fn from_data_owned<'de, T>(data: Data<'_>) -> Result +where + T: serde::Deserialize<'de>, +{ + T::deserialize(data.into_static()) +} + +/// Deserialize a typed value from a `serde_json::Value` +/// +/// Returns an owned version, will allocate +pub fn from_json_value<'de, T>( + json: serde_json::Value, +) -> Result<::Output, serde_json::Error> +where + T: serde::Deserialize<'de> + IntoStatic, +{ + T::deserialize(json).map(IntoStatic::into_static) +} + /// Deserialize a typed value from a `RawData` value /// /// Allows extracting strongly-typed structures from untyped `RawData` values. @@ -413,6 +462,16 @@ where T::deserialize(data) } +/// Deserialize a typed value from a `RawData` value +/// +/// Takes ownership rather than borrows. Will allocate. +pub fn from_raw_data_owned<'de, T>(data: RawData<'_>) -> Result +where + T: serde::Deserialize<'de>, +{ + T::deserialize(data.into_static()) +} + /// Serialize a typed value into a `RawData` value /// /// Allows converting strongly-typed structures into untyped `RawData` values. @@ -469,9 +528,8 @@ pub fn to_data(value: &T) -> Result, convert::ConversionError> where T: serde::Serialize, { - let raw = to_raw_data(value) - .map_err(|e| convert::ConversionError::InvalidRawData { - message: e.to_string() - })?; + let raw = to_raw_data(value).map_err(|e| convert::ConversionError::InvalidRawData { + message: e.to_string(), + })?; raw.try_into() } diff --git a/crates/jacquard-common/src/types/value/serde_impl.rs b/crates/jacquard-common/src/types/value/serde_impl.rs index 558604e70..f0f9249c4 100644 --- a/crates/jacquard-common/src/types/value/serde_impl.rs +++ b/crates/jacquard-common/src/types/value/serde_impl.rs @@ -843,6 +843,37 @@ impl<'de> serde::Deserializer<'de> for &'de Data<'de> { } } +// Deserializer implementation for &Data<'de> - allows deserializing typed data from Data values +impl<'de> serde::Deserializer<'de> for Data<'static> { + type Error = DataDeserializerError; + + fn deserialize_any(self, visitor: V) -> Result + where + V: serde::de::Visitor<'de>, + { + match self { + Data::Null => visitor.visit_unit(), + Data::Boolean(b) => visitor.visit_bool(b), + Data::Integer(i) => visitor.visit_i64(i), + Data::String(s) => visitor.visit_str(s.as_str()), + Data::Bytes(b) => visitor.visit_bytes(b.as_ref()), + Data::CidLink(cid) => visitor.visit_str(cid.as_str()), + Data::Array(arr) => visitor.visit_seq(OwnedArrayDeserializer::new(arr.0)), + Data::Object(obj) => visitor.visit_map(OwnedObjectDeserializer::new(obj.0)), + Data::Blob(blob) => { + // Blob is a root type - deserialize as the Blob itself via map representation + visitor.visit_map(OwnedBlobDeserializer::new(blob)) + } + } + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string + bytes byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map struct enum identifier ignored_any + } +} + // Deserializer implementation for &RawData<'de> impl<'de> serde::Deserializer<'de> for &'de RawData<'de> { type Error = DataDeserializerError; @@ -878,6 +909,41 @@ impl<'de> serde::Deserializer<'de> for &'de RawData<'de> { } } +// Deserializer implementation for &RawData<'de> +impl<'de> serde::Deserializer<'de> for RawData<'static> { + type Error = DataDeserializerError; + + fn deserialize_any(self, visitor: V) -> Result + where + V: serde::de::Visitor<'de>, + { + match self { + RawData::Null => visitor.visit_unit(), + RawData::Boolean(b) => visitor.visit_bool(b), + RawData::SignedInt(i) => visitor.visit_i64(i), + RawData::UnsignedInt(u) => visitor.visit_u64(u), + RawData::String(cow) => match cow { + CowStr::Borrowed(s) => visitor.visit_borrowed_str(s), + CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), + }, + RawData::Bytes(b) => visitor.visit_bytes(b.as_ref()), + RawData::CidLink(cid) => visitor.visit_str(cid.as_str()), + RawData::Array(arr) => visitor.visit_seq(RawOwnedArrayDeserializer::new(arr)), + RawData::Object(obj) => visitor.visit_map(RawOwnedObjectDeserializer::new(obj)), + RawData::Blob(blob) => visitor.visit_map(OwnedBlobDeserializer::new(blob)), + RawData::InvalidBlob(data) => data.deserialize_any(visitor), + RawData::InvalidNumber(bytes) => visitor.visit_bytes(bytes.as_ref()), + RawData::InvalidData(bytes) => visitor.visit_bytes(bytes.as_ref()), + } + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string + bytes byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map struct enum identifier ignored_any + } +} + /// Error type for Data/RawData deserializer #[derive(Debug, Clone, thiserror::Error)] pub enum DataDeserializerError { @@ -955,6 +1021,54 @@ impl<'de> serde::de::MapAccess<'de> for BlobDeserializer<'de> { } } +struct OwnedBlobDeserializer { + blob: Blob<'static>, + field_index: usize, +} + +impl OwnedBlobDeserializer { + fn new(blob: Blob<'_>) -> Self { + Self { + blob: blob.into_static(), + field_index: 0, + } + } +} + +impl<'de> serde::de::MapAccess<'de> for OwnedBlobDeserializer { + type Error = DataDeserializerError; + + fn next_key_seed(&mut self, seed: K) -> Result, Self::Error> + where + K: serde::de::DeserializeSeed<'de>, + { + let key = match self.field_index { + 0 => "$type", + 1 => "ref", + 2 => "mimeType", + 3 => "size", + _ => return Ok(None), + }; + self.field_index += 1; + seed.deserialize(BorrowedStrDeserializer(key)).map(Some) + } + + fn next_value_seed(&mut self, seed: V) -> Result + where + V: serde::de::DeserializeSeed<'de>, + { + match self.field_index - 1 { + 0 => seed.deserialize(OwnedStrDeserializer("blob".into())), + 1 => seed.deserialize(OwnedStrDeserializer(self.blob.r#ref.to_smolstr())), + 2 => seed.deserialize(OwnedStrDeserializer(self.blob.mime_type.to_smolstr())), + 3 => seed.deserialize(I64Deserializer(self.blob.size as i64)), + _ => Err(DataDeserializerError::Message( + "invalid field index".to_string(), + )), + } + } +} + // Helper deserializer for borrowed strings struct BorrowedStrDeserializer<'de>(&'de str); @@ -975,6 +1089,26 @@ impl<'de> serde::Deserializer<'de> for BorrowedStrDeserializer<'de> { } } +// Helper deserializer for borrowed strings +struct OwnedStrDeserializer(SmolStr); + +impl<'de> serde::Deserializer<'de> for OwnedStrDeserializer { + type Error = DataDeserializerError; + + fn deserialize_any(self, visitor: V) -> Result + where + V: serde::de::Visitor<'de>, + { + visitor.visit_str(&self.0) + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string + bytes byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map struct enum identifier ignored_any + } +} + // Helper deserializer for i64 values struct I64Deserializer(i64); @@ -1020,6 +1154,33 @@ impl<'de> serde::de::SeqAccess<'de> for ArrayDeserializer<'de> { } } +// SeqAccess implementation for Data::Array +struct OwnedArrayDeserializer { + iter: std::vec::IntoIter>, +} + +impl OwnedArrayDeserializer { + fn new(slice: Vec>) -> Self { + Self { + iter: slice.into_iter(), + } + } +} + +impl<'de> serde::de::SeqAccess<'de> for OwnedArrayDeserializer { + type Error = DataDeserializerError; + + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: serde::de::DeserializeSeed<'de>, + { + match self.iter.next() { + Some(value) => seed.deserialize(value).map(Some), + None => Ok(None), + } + } +} + // MapAccess implementation for Data::Object struct ObjectDeserializer<'de> { iter: std::collections::btree_map::Iter<'de, SmolStr, Data<'de>>, @@ -1065,6 +1226,50 @@ impl<'de> serde::de::MapAccess<'de> for ObjectDeserializer<'de> { } } +// MapAccess implementation for Data::Object +struct OwnedObjectDeserializer { + iter: std::collections::btree_map::IntoIter>, + value: Option>, +} + +impl OwnedObjectDeserializer { + fn new(map: BTreeMap>) -> Self { + Self { + iter: map.into_iter(), + value: None, + } + } +} + +impl<'de> serde::de::MapAccess<'de> for OwnedObjectDeserializer { + type Error = DataDeserializerError; + + fn next_key_seed(&mut self, seed: K) -> Result, Self::Error> + where + K: serde::de::DeserializeSeed<'de>, + { + match self.iter.next() { + Some((key, value)) => { + self.value = Some(value); + seed.deserialize(OwnedStrDeserializer(key)).map(Some) + } + None => Ok(None), + } + } + + fn next_value_seed(&mut self, seed: V) -> Result + where + V: serde::de::DeserializeSeed<'de>, + { + match self.value.take() { + Some(value) => seed.deserialize(value), + None => Err(DataDeserializerError::Message( + "value is missing".to_string(), + )), + } + } +} + // SeqAccess implementation for RawData::Array struct RawArrayDeserializer<'de> { iter: std::slice::Iter<'de, RawData<'de>>, @@ -1090,6 +1295,33 @@ impl<'de> serde::de::SeqAccess<'de> for RawArrayDeserializer<'de> { } } +// SeqAccess implementation for RawData::Array +struct RawOwnedArrayDeserializer<'de> { + iter: std::vec::IntoIter>, +} + +impl<'de> RawOwnedArrayDeserializer<'de> { + fn new(data: Vec>) -> Self { + Self { + iter: data.into_iter(), + } + } +} + +impl<'de> serde::de::SeqAccess<'de> for RawOwnedArrayDeserializer<'de> { + type Error = DataDeserializerError; + + fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> + where + T: serde::de::DeserializeSeed<'de>, + { + match self.iter.next() { + Some(value) => seed.deserialize(value.into_static()).map(Some), + None => Ok(None), + } + } +} + // MapAccess implementation for RawData::Object struct RawObjectDeserializer<'de> { iter: std::collections::btree_map::Iter<'de, SmolStr, RawData<'de>>, @@ -1135,6 +1367,50 @@ impl<'de> serde::de::MapAccess<'de> for RawObjectDeserializer<'de> { } } +// MapAccess implementation for RawData::Object +struct RawOwnedObjectDeserializer<'de> { + iter: std::collections::btree_map::IntoIter>, + value: Option>, +} + +impl<'de> RawOwnedObjectDeserializer<'de> { + fn new(map: BTreeMap>) -> Self { + Self { + iter: map.into_iter(), + value: None, + } + } +} + +impl<'de> serde::de::MapAccess<'de> for RawOwnedObjectDeserializer<'de> { + type Error = DataDeserializerError; + + fn next_key_seed(&mut self, seed: K) -> Result, Self::Error> + where + K: serde::de::DeserializeSeed<'de>, + { + match self.iter.next() { + Some((key, value)) => { + self.value = Some(value); + seed.deserialize(OwnedStrDeserializer(key)).map(Some) + } + None => Ok(None), + } + } + + fn next_value_seed(&mut self, seed: V) -> Result + where + V: serde::de::DeserializeSeed<'de>, + { + match self.value.take() { + Some(value) => seed.deserialize(value.into_static()), + None => Err(DataDeserializerError::Message( + "value is missing".to_string(), + )), + } + } +} + /// Error type for RawData serialization #[derive(Debug)] pub enum RawDataSerializerError { diff --git a/crates/jacquard-common/src/types/value/tests.rs b/crates/jacquard-common/src/types/value/tests.rs index e0c1ca833..d5e4ba8fd 100644 --- a/crates/jacquard-common/src/types/value/tests.rs +++ b/crates/jacquard-common/src/types/value/tests.rs @@ -645,6 +645,32 @@ fn test_atidentifier_deserialization() { } } +#[test] +fn test_json_value_deser() { + // if this compiles, it works. + let json = serde_json::json!({"name": "alice", "age": 30, "active": true}); + #[derive(Debug, serde::Deserialize)] + struct TestStruct<'a> { + #[serde(borrow)] + name: CowStr<'a>, + age: i64, + active: bool, + } + + impl IntoStatic for TestStruct<'_> { + type Output = TestStruct<'static>; + fn into_static(self) -> Self::Output { + TestStruct { + name: self.name.into_static(), + age: self.age, + active: self.active, + } + } + } + + let _result = from_json_value::(json).expect("should be right struct"); +} + #[test] fn test_to_raw_data() { use serde::Serialize; diff --git a/crates/jacquard-identity/Cargo.toml b/crates/jacquard-identity/Cargo.toml index 3a4464179..e8b27c0a5 100644 --- a/crates/jacquard-identity/Cargo.toml +++ b/crates/jacquard-identity/Cargo.toml @@ -21,7 +21,7 @@ async-trait.workspace = true bon.workspace = true bytes.workspace = true jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] } -jacquard-api = { version = "0.5", path = "../jacquard-api" } +jacquard-api = { version = "0.5", path = "../jacquard-api", default-features = false, features = ["minimal"] } percent-encoding.workspace = true reqwest.workspace = true url.workspace = true diff --git a/crates/jacquard/Cargo.toml b/crates/jacquard/Cargo.toml index d16c6c697..74f72f6bc 100644 --- a/crates/jacquard/Cargo.toml +++ b/crates/jacquard/Cargo.toml @@ -15,7 +15,7 @@ license.workspace = true default = ["api_full", "dns", "loopback", "derive"] derive = ["dep:jacquard-derive"] # Minimal API bindings -api = ["jacquard-api/com_atproto", "jacquard-api/com_bad_example" ] +api = ["jacquard-api/minimal"] # Bluesky API bindings api_bluesky = ["api", "jacquard-api/bluesky" ] # Bluesky API bindings, plus a curated selection of community lexicons diff --git a/crates/jacquard/src/client.rs b/crates/jacquard/src/client.rs index 987ca42d6..e3487250d 100644 --- a/crates/jacquard/src/client.rs +++ b/crates/jacquard/src/client.rs @@ -24,14 +24,6 @@ pub mod token; pub mod vec_update; use core::future::Future; - -use jacquard_api::com_atproto::repo::create_record::CreateRecordOutput; -use jacquard_api::com_atproto::repo::delete_record::DeleteRecordOutput; -use jacquard_api::com_atproto::repo::get_record::GetRecordResponse; -use jacquard_api::com_atproto::repo::put_record::PutRecordOutput; -use jacquard_api::com_atproto::repo::upload_blob::UploadBlobResponse; -use jacquard_api::com_atproto::server::create_session::CreateSessionOutput; -use jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput; use jacquard_common::error::TransportError; pub use jacquard_common::error::{ClientError, XrpcResult}; use jacquard_common::http_client::HttpClient; @@ -323,6 +315,16 @@ impl Agent { } } +#[cfg(feature = "api")] +use jacquard_api::com_atproto::{ + repo::{ + create_record::CreateRecordOutput, delete_record::DeleteRecordOutput, + get_record::GetRecordResponse, put_record::PutRecordOutput, + upload_blob::UploadBlobResponse, + }, + server::{create_session::CreateSessionOutput, refresh_session::RefreshSessionOutput}, +}; + /// Extension trait providing convenience methods for common repository operations. /// /// This trait is automatically implemented for any type that implements both @@ -365,6 +367,7 @@ impl Agent { /// # Ok(()) /// # } /// ``` +#[cfg(feature = "api")] pub trait AgentSessionExt: AgentSession + IdentityResolver { /// Create a new record in the repository. /// @@ -477,7 +480,8 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { { async move { #[cfg(feature = "tracing")] - let _span = tracing::debug_span!("get_record", collection = %R::nsid(), uri = %uri).entered(); + let _span = + tracing::debug_span!("get_record", collection = %R::nsid(), uri = %uri).entered(); // Validate that URI's collection matches the expected type if let Some(uri_collection) = uri.collection() { @@ -575,7 +579,8 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { { async move { #[cfg(feature = "tracing")] - let _span = tracing::debug_span!("update_record", collection = %R::nsid(), uri = %uri).entered(); + let _span = tracing::debug_span!("update_record", collection = %R::nsid(), uri = %uri) + .entered(); // Fetch the record - Response where R::Record::Output<'de> = R<'de> let response = self.get_record::(uri.clone()).await?; diff --git a/justfile b/justfile index 50a08cce9..f43180c46 100644 --- a/justfile +++ b/justfile @@ -48,10 +48,10 @@ examples: example NAME *ARGS: #!/usr/bin/env bash if [ -f "examples/{{NAME}}.rs" ]; then - cargo run -p jacquard --example {{NAME}} -- {{ARGS}} + cargo run -p jacquard --features=api_bluesky --example {{NAME}} -- {{ARGS}} elif cargo metadata --format-version=1 --no-deps | \ jq -e '.packages[] | select(.name == "jacquard-axum") | .targets[] | select(.kind[] == "example" and .name == "{{NAME}}")' > /dev/null; then - cargo run -p jacquard-axum --example {{NAME}} --features api_bluesky -- {{ARGS}} + cargo run -p jacquard-axum --example {{NAME}} -- {{ARGS}} else echo "Example '{{NAME}}' not found." echo ""