A better Rust ATProto crate

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

+394 -19
+9 -1
CHANGELOG.md
··· 4 4 5 5 ### Added 6 6 7 + **Value type deserialization** (`jacquard-common`) 8 + - `from_json_value()`: Deserialize typed data directly from `serde_json::Value` without borrowing 9 + - `from_data_owned()`, `from_raw_data_owned()`: Owned deserialization helpers 10 + - `Data::from_json_owned()`: Parse JSON into owned `Data<'static>` 11 + - `IntoStatic` implementation for `RawData` enabling owned conversions 12 + - Re-exported value types from crate root for easier imports 13 + - `Deserializer` trait implementations for `Data<'static>` and `RawData<'static>` 14 + - Owned deserializer helpers: `OwnedArrayDeserializer`, `OwnedObjectDeserializer`, `OwnedBlobDeserializer` 15 + 7 16 **Service Auth** (`jacquard-axum`, `jacquard-common`) 8 17 - Full service authentication implementation for inter-service JWT verification 9 18 - `ExtractServiceAuth` Axum extractor for validating service auth tokens ··· 11 20 - Service auth claims validation (issuer, audience, expiration, method binding) 12 21 - DID document resolution for signing key verification 13 22 - Optional replay protection via `ReplayTracker` trait 14 - - See CLAUDE.md for detailed implementation notes 15 23 16 24 **XrpcRequest derive macro** (`jacquard-derive`) 17 25 - `#[derive(XrpcRequest)]` for custom XRPC endpoints
+2
crates/jacquard-common/src/lib.rs
··· 221 221 // XRPC protocol types and traits 222 222 pub mod xrpc; 223 223 224 + pub use types::value::*; 225 + 224 226 /// Authorization token types for XRPC requests. 225 227 #[derive(Debug, Clone)] 226 228 pub enum AuthorizationToken<'s> {
+62 -4
crates/jacquard-common/src/types/value.rs
··· 112 112 }) 113 113 } 114 114 115 + /// Parse a Data value from a JSON value (owned) 116 + pub fn from_json_owned(json: serde_json::Value) -> Result<Data<'static>, AtDataError> { 117 + Data::from_json(&json).map(|data| data.into_static()) 118 + } 119 + 115 120 /// Parse a Data value from an IPLD value (CBOR) 116 121 pub fn from_cbor(cbor: &'s Ipld) -> Result<Self, AtDataError> { 117 122 Ok(match cbor { ··· 352 357 InvalidData(Bytes), 353 358 } 354 359 360 + impl IntoStatic for RawData<'_> { 361 + type Output = RawData<'static>; 362 + 363 + fn into_static(self) -> Self::Output { 364 + match self { 365 + RawData::Null => RawData::Null, 366 + RawData::Boolean(b) => RawData::Boolean(b), 367 + RawData::SignedInt(i) => RawData::SignedInt(i), 368 + RawData::UnsignedInt(u) => RawData::UnsignedInt(u), 369 + RawData::String(s) => RawData::String(s.into_static()), 370 + RawData::Bytes(b) => RawData::Bytes(b.into_static()), 371 + RawData::CidLink(c) => RawData::CidLink(c.into_static()), 372 + RawData::Array(a) => RawData::Array(a.into_static()), 373 + RawData::Object(o) => RawData::Object(o.into_static()), 374 + RawData::Blob(b) => RawData::Blob(b.into_static()), 375 + RawData::InvalidBlob(b) => RawData::InvalidBlob(b.into_static()), 376 + RawData::InvalidNumber(b) => RawData::InvalidNumber(b.into_static()), 377 + RawData::InvalidData(b) => RawData::InvalidData(b.into_static()), 378 + } 379 + } 380 + } 381 + 355 382 /// Deserialize a typed value from a `Data` value 356 383 /// 357 384 /// Allows extracting strongly-typed structures from untyped `Data` values, ··· 384 411 T::deserialize(data) 385 412 } 386 413 414 + /// Deserialize a typed value from a `Data` value 415 + /// 416 + /// Takes ownership rather than borrows. Will allocate. 417 + pub fn from_data_owned<'de, T>(data: Data<'_>) -> Result<T, DataDeserializerError> 418 + where 419 + T: serde::Deserialize<'de>, 420 + { 421 + T::deserialize(data.into_static()) 422 + } 423 + 424 + /// Deserialize a typed value from a `serde_json::Value` 425 + /// 426 + /// Returns an owned version, will allocate 427 + pub fn from_json_value<'de, T>( 428 + json: serde_json::Value, 429 + ) -> Result<<T as IntoStatic>::Output, serde_json::Error> 430 + where 431 + T: serde::Deserialize<'de> + IntoStatic, 432 + { 433 + T::deserialize(json).map(IntoStatic::into_static) 434 + } 435 + 387 436 /// Deserialize a typed value from a `RawData` value 388 437 /// 389 438 /// Allows extracting strongly-typed structures from untyped `RawData` values. ··· 411 460 T: serde::Deserialize<'de>, 412 461 { 413 462 T::deserialize(data) 463 + } 464 + 465 + /// Deserialize a typed value from a `RawData` value 466 + /// 467 + /// Takes ownership rather than borrows. Will allocate. 468 + pub fn from_raw_data_owned<'de, T>(data: RawData<'_>) -> Result<T, DataDeserializerError> 469 + where 470 + T: serde::Deserialize<'de>, 471 + { 472 + T::deserialize(data.into_static()) 414 473 } 415 474 416 475 /// Serialize a typed value into a `RawData` value ··· 469 528 where 470 529 T: serde::Serialize, 471 530 { 472 - let raw = to_raw_data(value) 473 - .map_err(|e| convert::ConversionError::InvalidRawData { 474 - message: e.to_string() 475 - })?; 531 + let raw = to_raw_data(value).map_err(|e| convert::ConversionError::InvalidRawData { 532 + message: e.to_string(), 533 + })?; 476 534 raw.try_into() 477 535 }
+276
crates/jacquard-common/src/types/value/serde_impl.rs
··· 843 843 } 844 844 } 845 845 846 + // Deserializer implementation for &Data<'de> - allows deserializing typed data from Data values 847 + impl<'de> serde::Deserializer<'de> for Data<'static> { 848 + type Error = DataDeserializerError; 849 + 850 + fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error> 851 + where 852 + V: serde::de::Visitor<'de>, 853 + { 854 + match self { 855 + Data::Null => visitor.visit_unit(), 856 + Data::Boolean(b) => visitor.visit_bool(b), 857 + Data::Integer(i) => visitor.visit_i64(i), 858 + Data::String(s) => visitor.visit_str(s.as_str()), 859 + Data::Bytes(b) => visitor.visit_bytes(b.as_ref()), 860 + Data::CidLink(cid) => visitor.visit_str(cid.as_str()), 861 + Data::Array(arr) => visitor.visit_seq(OwnedArrayDeserializer::new(arr.0)), 862 + Data::Object(obj) => visitor.visit_map(OwnedObjectDeserializer::new(obj.0)), 863 + Data::Blob(blob) => { 864 + // Blob is a root type - deserialize as the Blob itself via map representation 865 + visitor.visit_map(OwnedBlobDeserializer::new(blob)) 866 + } 867 + } 868 + } 869 + 870 + serde::forward_to_deserialize_any! { 871 + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string 872 + bytes byte_buf option unit unit_struct newtype_struct seq tuple 873 + tuple_struct map struct enum identifier ignored_any 874 + } 875 + } 876 + 846 877 // Deserializer implementation for &RawData<'de> 847 878 impl<'de> serde::Deserializer<'de> for &'de RawData<'de> { 848 879 type Error = DataDeserializerError; ··· 878 909 } 879 910 } 880 911 912 + // Deserializer implementation for &RawData<'de> 913 + impl<'de> serde::Deserializer<'de> for RawData<'static> { 914 + type Error = DataDeserializerError; 915 + 916 + fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error> 917 + where 918 + V: serde::de::Visitor<'de>, 919 + { 920 + match self { 921 + RawData::Null => visitor.visit_unit(), 922 + RawData::Boolean(b) => visitor.visit_bool(b), 923 + RawData::SignedInt(i) => visitor.visit_i64(i), 924 + RawData::UnsignedInt(u) => visitor.visit_u64(u), 925 + RawData::String(cow) => match cow { 926 + CowStr::Borrowed(s) => visitor.visit_borrowed_str(s), 927 + CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 928 + }, 929 + RawData::Bytes(b) => visitor.visit_bytes(b.as_ref()), 930 + RawData::CidLink(cid) => visitor.visit_str(cid.as_str()), 931 + RawData::Array(arr) => visitor.visit_seq(RawOwnedArrayDeserializer::new(arr)), 932 + RawData::Object(obj) => visitor.visit_map(RawOwnedObjectDeserializer::new(obj)), 933 + RawData::Blob(blob) => visitor.visit_map(OwnedBlobDeserializer::new(blob)), 934 + RawData::InvalidBlob(data) => data.deserialize_any(visitor), 935 + RawData::InvalidNumber(bytes) => visitor.visit_bytes(bytes.as_ref()), 936 + RawData::InvalidData(bytes) => visitor.visit_bytes(bytes.as_ref()), 937 + } 938 + } 939 + 940 + serde::forward_to_deserialize_any! { 941 + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string 942 + bytes byte_buf option unit unit_struct newtype_struct seq tuple 943 + tuple_struct map struct enum identifier ignored_any 944 + } 945 + } 946 + 881 947 /// Error type for Data/RawData deserializer 882 948 #[derive(Debug, Clone, thiserror::Error)] 883 949 pub enum DataDeserializerError { ··· 955 1021 } 956 1022 } 957 1023 1024 + struct OwnedBlobDeserializer { 1025 + blob: Blob<'static>, 1026 + field_index: usize, 1027 + } 1028 + 1029 + impl OwnedBlobDeserializer { 1030 + fn new(blob: Blob<'_>) -> Self { 1031 + Self { 1032 + blob: blob.into_static(), 1033 + field_index: 0, 1034 + } 1035 + } 1036 + } 1037 + 1038 + impl<'de> serde::de::MapAccess<'de> for OwnedBlobDeserializer { 1039 + type Error = DataDeserializerError; 1040 + 1041 + fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error> 1042 + where 1043 + K: serde::de::DeserializeSeed<'de>, 1044 + { 1045 + let key = match self.field_index { 1046 + 0 => "$type", 1047 + 1 => "ref", 1048 + 2 => "mimeType", 1049 + 3 => "size", 1050 + _ => return Ok(None), 1051 + }; 1052 + self.field_index += 1; 1053 + seed.deserialize(BorrowedStrDeserializer(key)).map(Some) 1054 + } 1055 + 1056 + fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error> 1057 + where 1058 + V: serde::de::DeserializeSeed<'de>, 1059 + { 1060 + match self.field_index - 1 { 1061 + 0 => seed.deserialize(OwnedStrDeserializer("blob".into())), 1062 + 1 => seed.deserialize(OwnedStrDeserializer(self.blob.r#ref.to_smolstr())), 1063 + 2 => seed.deserialize(OwnedStrDeserializer(self.blob.mime_type.to_smolstr())), 1064 + 3 => seed.deserialize(I64Deserializer(self.blob.size as i64)), 1065 + _ => Err(DataDeserializerError::Message( 1066 + "invalid field index".to_string(), 1067 + )), 1068 + } 1069 + } 1070 + } 1071 + 958 1072 // Helper deserializer for borrowed strings 959 1073 struct BorrowedStrDeserializer<'de>(&'de str); 960 1074 ··· 966 1080 V: serde::de::Visitor<'de>, 967 1081 { 968 1082 visitor.visit_borrowed_str(self.0) 1083 + } 1084 + 1085 + serde::forward_to_deserialize_any! { 1086 + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string 1087 + bytes byte_buf option unit unit_struct newtype_struct seq tuple 1088 + tuple_struct map struct enum identifier ignored_any 1089 + } 1090 + } 1091 + 1092 + // Helper deserializer for borrowed strings 1093 + struct OwnedStrDeserializer(SmolStr); 1094 + 1095 + impl<'de> serde::Deserializer<'de> for OwnedStrDeserializer { 1096 + type Error = DataDeserializerError; 1097 + 1098 + fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error> 1099 + where 1100 + V: serde::de::Visitor<'de>, 1101 + { 1102 + visitor.visit_str(&self.0) 969 1103 } 970 1104 971 1105 serde::forward_to_deserialize_any! { ··· 1020 1154 } 1021 1155 } 1022 1156 1157 + // SeqAccess implementation for Data::Array 1158 + struct OwnedArrayDeserializer { 1159 + iter: std::vec::IntoIter<Data<'static>>, 1160 + } 1161 + 1162 + impl OwnedArrayDeserializer { 1163 + fn new(slice: Vec<Data<'static>>) -> Self { 1164 + Self { 1165 + iter: slice.into_iter(), 1166 + } 1167 + } 1168 + } 1169 + 1170 + impl<'de> serde::de::SeqAccess<'de> for OwnedArrayDeserializer { 1171 + type Error = DataDeserializerError; 1172 + 1173 + fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error> 1174 + where 1175 + T: serde::de::DeserializeSeed<'de>, 1176 + { 1177 + match self.iter.next() { 1178 + Some(value) => seed.deserialize(value).map(Some), 1179 + None => Ok(None), 1180 + } 1181 + } 1182 + } 1183 + 1023 1184 // MapAccess implementation for Data::Object 1024 1185 struct ObjectDeserializer<'de> { 1025 1186 iter: std::collections::btree_map::Iter<'de, SmolStr, Data<'de>>, ··· 1065 1226 } 1066 1227 } 1067 1228 1229 + // MapAccess implementation for Data::Object 1230 + struct OwnedObjectDeserializer { 1231 + iter: std::collections::btree_map::IntoIter<SmolStr, Data<'static>>, 1232 + value: Option<Data<'static>>, 1233 + } 1234 + 1235 + impl OwnedObjectDeserializer { 1236 + fn new(map: BTreeMap<SmolStr, Data<'static>>) -> Self { 1237 + Self { 1238 + iter: map.into_iter(), 1239 + value: None, 1240 + } 1241 + } 1242 + } 1243 + 1244 + impl<'de> serde::de::MapAccess<'de> for OwnedObjectDeserializer { 1245 + type Error = DataDeserializerError; 1246 + 1247 + fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error> 1248 + where 1249 + K: serde::de::DeserializeSeed<'de>, 1250 + { 1251 + match self.iter.next() { 1252 + Some((key, value)) => { 1253 + self.value = Some(value); 1254 + seed.deserialize(OwnedStrDeserializer(key)).map(Some) 1255 + } 1256 + None => Ok(None), 1257 + } 1258 + } 1259 + 1260 + fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error> 1261 + where 1262 + V: serde::de::DeserializeSeed<'de>, 1263 + { 1264 + match self.value.take() { 1265 + Some(value) => seed.deserialize(value), 1266 + None => Err(DataDeserializerError::Message( 1267 + "value is missing".to_string(), 1268 + )), 1269 + } 1270 + } 1271 + } 1272 + 1068 1273 // SeqAccess implementation for RawData::Array 1069 1274 struct RawArrayDeserializer<'de> { 1070 1275 iter: std::slice::Iter<'de, RawData<'de>>, ··· 1090 1295 } 1091 1296 } 1092 1297 1298 + // SeqAccess implementation for RawData::Array 1299 + struct RawOwnedArrayDeserializer<'de> { 1300 + iter: std::vec::IntoIter<RawData<'de>>, 1301 + } 1302 + 1303 + impl<'de> RawOwnedArrayDeserializer<'de> { 1304 + fn new(data: Vec<RawData<'de>>) -> Self { 1305 + Self { 1306 + iter: data.into_iter(), 1307 + } 1308 + } 1309 + } 1310 + 1311 + impl<'de> serde::de::SeqAccess<'de> for RawOwnedArrayDeserializer<'de> { 1312 + type Error = DataDeserializerError; 1313 + 1314 + fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error> 1315 + where 1316 + T: serde::de::DeserializeSeed<'de>, 1317 + { 1318 + match self.iter.next() { 1319 + Some(value) => seed.deserialize(value.into_static()).map(Some), 1320 + None => Ok(None), 1321 + } 1322 + } 1323 + } 1324 + 1093 1325 // MapAccess implementation for RawData::Object 1094 1326 struct RawObjectDeserializer<'de> { 1095 1327 iter: std::collections::btree_map::Iter<'de, SmolStr, RawData<'de>>, ··· 1128 1360 { 1129 1361 match self.value.take() { 1130 1362 Some(value) => seed.deserialize(value), 1363 + None => Err(DataDeserializerError::Message( 1364 + "value is missing".to_string(), 1365 + )), 1366 + } 1367 + } 1368 + } 1369 + 1370 + // MapAccess implementation for RawData::Object 1371 + struct RawOwnedObjectDeserializer<'de> { 1372 + iter: std::collections::btree_map::IntoIter<SmolStr, RawData<'de>>, 1373 + value: Option<RawData<'de>>, 1374 + } 1375 + 1376 + impl<'de> RawOwnedObjectDeserializer<'de> { 1377 + fn new(map: BTreeMap<SmolStr, RawData<'de>>) -> Self { 1378 + Self { 1379 + iter: map.into_iter(), 1380 + value: None, 1381 + } 1382 + } 1383 + } 1384 + 1385 + impl<'de> serde::de::MapAccess<'de> for RawOwnedObjectDeserializer<'de> { 1386 + type Error = DataDeserializerError; 1387 + 1388 + fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error> 1389 + where 1390 + K: serde::de::DeserializeSeed<'de>, 1391 + { 1392 + match self.iter.next() { 1393 + Some((key, value)) => { 1394 + self.value = Some(value); 1395 + seed.deserialize(OwnedStrDeserializer(key)).map(Some) 1396 + } 1397 + None => Ok(None), 1398 + } 1399 + } 1400 + 1401 + fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error> 1402 + where 1403 + V: serde::de::DeserializeSeed<'de>, 1404 + { 1405 + match self.value.take() { 1406 + Some(value) => seed.deserialize(value.into_static()), 1131 1407 None => Err(DataDeserializerError::Message( 1132 1408 "value is missing".to_string(), 1133 1409 )),
+26
crates/jacquard-common/src/types/value/tests.rs
··· 646 646 } 647 647 648 648 #[test] 649 + fn test_json_value_deser() { 650 + // if this compiles, it works. 651 + let json = serde_json::json!({"name": "alice", "age": 30, "active": true}); 652 + #[derive(Debug, serde::Deserialize)] 653 + struct TestStruct<'a> { 654 + #[serde(borrow)] 655 + name: CowStr<'a>, 656 + age: i64, 657 + active: bool, 658 + } 659 + 660 + impl IntoStatic for TestStruct<'_> { 661 + type Output = TestStruct<'static>; 662 + fn into_static(self) -> Self::Output { 663 + TestStruct { 664 + name: self.name.into_static(), 665 + age: self.age, 666 + active: self.active, 667 + } 668 + } 669 + } 670 + 671 + let _result = from_json_value::<TestStruct>(json).expect("should be right struct"); 672 + } 673 + 674 + #[test] 649 675 fn test_to_raw_data() { 650 676 use serde::Serialize; 651 677
+1 -1
crates/jacquard-identity/Cargo.toml
··· 21 21 bon.workspace = true 22 22 bytes.workspace = true 23 23 jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] } 24 - jacquard-api = { version = "0.5", path = "../jacquard-api" } 24 + jacquard-api = { version = "0.5", path = "../jacquard-api", default-features = false, features = ["minimal"] } 25 25 percent-encoding.workspace = true 26 26 reqwest.workspace = true 27 27 url.workspace = true
+1 -1
crates/jacquard/Cargo.toml
··· 15 15 default = ["api_full", "dns", "loopback", "derive"] 16 16 derive = ["dep:jacquard-derive"] 17 17 # Minimal API bindings 18 - api = ["jacquard-api/com_atproto", "jacquard-api/com_bad_example" ] 18 + api = ["jacquard-api/minimal"] 19 19 # Bluesky API bindings 20 20 api_bluesky = ["api", "jacquard-api/bluesky" ] 21 21 # Bluesky API bindings, plus a curated selection of community lexicons
+15 -10
crates/jacquard/src/client.rs
··· 24 24 pub mod vec_update; 25 25 26 26 use core::future::Future; 27 - 28 - use jacquard_api::com_atproto::repo::create_record::CreateRecordOutput; 29 - use jacquard_api::com_atproto::repo::delete_record::DeleteRecordOutput; 30 - use jacquard_api::com_atproto::repo::get_record::GetRecordResponse; 31 - use jacquard_api::com_atproto::repo::put_record::PutRecordOutput; 32 - use jacquard_api::com_atproto::repo::upload_blob::UploadBlobResponse; 33 - use jacquard_api::com_atproto::server::create_session::CreateSessionOutput; 34 - use jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput; 35 27 use jacquard_common::error::TransportError; 36 28 pub use jacquard_common::error::{ClientError, XrpcResult}; 37 29 use jacquard_common::http_client::HttpClient; ··· 323 315 } 324 316 } 325 317 318 + #[cfg(feature = "api")] 319 + use jacquard_api::com_atproto::{ 320 + repo::{ 321 + create_record::CreateRecordOutput, delete_record::DeleteRecordOutput, 322 + get_record::GetRecordResponse, put_record::PutRecordOutput, 323 + upload_blob::UploadBlobResponse, 324 + }, 325 + server::{create_session::CreateSessionOutput, refresh_session::RefreshSessionOutput}, 326 + }; 327 + 326 328 /// Extension trait providing convenience methods for common repository operations. 327 329 /// 328 330 /// This trait is automatically implemented for any type that implements both ··· 365 367 /// # Ok(()) 366 368 /// # } 367 369 /// ``` 370 + #[cfg(feature = "api")] 368 371 pub trait AgentSessionExt: AgentSession + IdentityResolver { 369 372 /// Create a new record in the repository. 370 373 /// ··· 477 480 { 478 481 async move { 479 482 #[cfg(feature = "tracing")] 480 - let _span = tracing::debug_span!("get_record", collection = %R::nsid(), uri = %uri).entered(); 483 + let _span = 484 + tracing::debug_span!("get_record", collection = %R::nsid(), uri = %uri).entered(); 481 485 482 486 // Validate that URI's collection matches the expected type 483 487 if let Some(uri_collection) = uri.collection() { ··· 575 579 { 576 580 async move { 577 581 #[cfg(feature = "tracing")] 578 - let _span = tracing::debug_span!("update_record", collection = %R::nsid(), uri = %uri).entered(); 582 + let _span = tracing::debug_span!("update_record", collection = %R::nsid(), uri = %uri) 583 + .entered(); 579 584 580 585 // Fetch the record - Response<R::Record> where R::Record::Output<'de> = R<'de> 581 586 let response = self.get_record::<R>(uri.clone()).await?;
+2 -2
justfile
··· 48 48 example NAME *ARGS: 49 49 #!/usr/bin/env bash 50 50 if [ -f "examples/{{NAME}}.rs" ]; then 51 - cargo run -p jacquard --example {{NAME}} -- {{ARGS}} 51 + cargo run -p jacquard --features=api_bluesky --example {{NAME}} -- {{ARGS}} 52 52 elif cargo metadata --format-version=1 --no-deps | \ 53 53 jq -e '.packages[] | select(.name == "jacquard-axum") | .targets[] | select(.kind[] == "example" and .name == "{{NAME}}")' > /dev/null; then 54 - cargo run -p jacquard-axum --example {{NAME}} --features api_bluesky -- {{ARGS}} 54 + cargo run -p jacquard-axum --example {{NAME}} -- {{ARGS}} 55 55 else 56 56 echo "Example '{{NAME}}' not found." 57 57 echo ""