A better Rust ATProto crate

serde_json::Value deser helper, owned deser from Data and RawData

Orual f3945760 4b9139d7

Changed files
+394 -19
crates
jacquard
jacquard-common
jacquard-identity
+9 -1
CHANGELOG.md
···
### 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
···
- 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
+2
crates/jacquard-common/src/lib.rs
···
// 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> {
+62 -4
crates/jacquard-common/src/types/value.rs
···
})
}
+
/// Parse a Data value from a JSON value (owned)
+
pub fn from_json_owned(json: serde_json::Value) -> Result<Data<'static>, 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<Self, AtDataError> {
Ok(match cbor {
···
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,
···
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<T, DataDeserializerError>
+
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<<T as IntoStatic>::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.
···
T: serde::Deserialize<'de>,
{
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<T, DataDeserializerError>
+
where
+
T: serde::Deserialize<'de>,
+
{
+
T::deserialize(data.into_static())
}
/// Serialize a typed value into a `RawData` value
···
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()
}
+276
crates/jacquard-common/src/types/value/serde_impl.rs
···
}
}
+
// 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<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+
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;
···
}
}
+
// Deserializer implementation for &RawData<'de>
+
impl<'de> serde::Deserializer<'de> for RawData<'static> {
+
type Error = DataDeserializerError;
+
+
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+
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 {
···
}
}
+
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<K>(&mut self, seed: K) -> Result<Option<K::Value>, 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<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
+
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);
···
V: serde::de::Visitor<'de>,
{
visitor.visit_borrowed_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 borrowed strings
+
struct OwnedStrDeserializer(SmolStr);
+
+
impl<'de> serde::Deserializer<'de> for OwnedStrDeserializer {
+
type Error = DataDeserializerError;
+
+
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
+
where
+
V: serde::de::Visitor<'de>,
+
{
+
visitor.visit_str(&self.0)
}
serde::forward_to_deserialize_any! {
···
+
// SeqAccess implementation for Data::Array
+
struct OwnedArrayDeserializer {
+
iter: std::vec::IntoIter<Data<'static>>,
+
}
+
+
impl OwnedArrayDeserializer {
+
fn new(slice: Vec<Data<'static>>) -> Self {
+
Self {
+
iter: slice.into_iter(),
+
}
+
}
+
}
+
+
impl<'de> serde::de::SeqAccess<'de> for OwnedArrayDeserializer {
+
type Error = DataDeserializerError;
+
+
fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, 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>>,
···
+
// MapAccess implementation for Data::Object
+
struct OwnedObjectDeserializer {
+
iter: std::collections::btree_map::IntoIter<SmolStr, Data<'static>>,
+
value: Option<Data<'static>>,
+
}
+
+
impl OwnedObjectDeserializer {
+
fn new(map: BTreeMap<SmolStr, Data<'static>>) -> Self {
+
Self {
+
iter: map.into_iter(),
+
value: None,
+
}
+
}
+
}
+
+
impl<'de> serde::de::MapAccess<'de> for OwnedObjectDeserializer {
+
type Error = DataDeserializerError;
+
+
fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, 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<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
+
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>>,
···
+
// SeqAccess implementation for RawData::Array
+
struct RawOwnedArrayDeserializer<'de> {
+
iter: std::vec::IntoIter<RawData<'de>>,
+
}
+
+
impl<'de> RawOwnedArrayDeserializer<'de> {
+
fn new(data: Vec<RawData<'de>>) -> Self {
+
Self {
+
iter: data.into_iter(),
+
}
+
}
+
}
+
+
impl<'de> serde::de::SeqAccess<'de> for RawOwnedArrayDeserializer<'de> {
+
type Error = DataDeserializerError;
+
+
fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, 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>>,
···
match self.value.take() {
Some(value) => seed.deserialize(value),
+
None => Err(DataDeserializerError::Message(
+
"value is missing".to_string(),
+
)),
+
}
+
}
+
}
+
+
// MapAccess implementation for RawData::Object
+
struct RawOwnedObjectDeserializer<'de> {
+
iter: std::collections::btree_map::IntoIter<SmolStr, RawData<'de>>,
+
value: Option<RawData<'de>>,
+
}
+
+
impl<'de> RawOwnedObjectDeserializer<'de> {
+
fn new(map: BTreeMap<SmolStr, RawData<'de>>) -> Self {
+
Self {
+
iter: map.into_iter(),
+
value: None,
+
}
+
}
+
}
+
+
impl<'de> serde::de::MapAccess<'de> for RawOwnedObjectDeserializer<'de> {
+
type Error = DataDeserializerError;
+
+
fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, 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<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
+
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(),
)),
+26
crates/jacquard-common/src/types/value/tests.rs
···
}
#[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::<TestStruct>(json).expect("should be right struct");
+
}
+
+
#[test]
fn test_to_raw_data() {
use serde::Serialize;
+1 -1
crates/jacquard-identity/Cargo.toml
···
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
+1 -1
crates/jacquard/Cargo.toml
···
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
+15 -10
crates/jacquard/src/client.rs
···
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;
···
}
}
+
#[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
···
/// # Ok(())
/// # }
/// ```
+
#[cfg(feature = "api")]
pub trait AgentSessionExt: AgentSession + IdentityResolver {
/// Create a new record in the repository.
///
···
{
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() {
···
{
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<R::Record> where R::Record::Output<'de> = R<'de>
let response = self.get_record::<R>(uri.clone()).await?;
+2 -2
justfile
···
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 ""