A better Rust ATProto crate

xrpc request derive macro

Orual aae802b5 c166b9be

Changed files
+364 -5
crates
jacquard
jacquard-derive
src
+237 -5
crates/jacquard-derive/src/lib.rs
···
//! # Derive macros for jacquard lexicon types
//!
-
//! This crate provides attribute macros that the code generator uses to add lexicon-specific
-
//! behavior to generated types. You'll rarely need to use these directly unless you're writing
-
//! custom lexicon types by hand. However, deriving IntoStatic will likely be very useful.
+
//! This crate provides attribute and derive macros for working with Jacquard types.
+
//! The code generator uses `#[lexicon]` and `#[open_union]` to add lexicon-specific behavior.
+
//! You'll use `#[derive(IntoStatic)]` frequently, and `#[derive(XrpcRequest)]` when defining
+
//! custom XRPC endpoints.
//!
//! ## Macros
//!
···
//! // fn into_static(self) -> Self::Output { ... }
//! // }
//! ```
+
//!
+
//! ### `#[derive(XrpcRequest)]`
+
//!
+
//! Derives XRPC request traits for custom endpoints. Generates the response marker struct
+
//! and implements `XrpcRequest` (and optionally `XrpcEndpoint` for server-side).
+
//!
+
//! ```ignore
+
//! #[derive(Serialize, Deserialize, XrpcRequest)]
+
//! #[xrpc(
+
//! nsid = "com.example.getThing",
+
//! method = Query,
+
//! output = GetThingOutput,
+
//! )]
+
//! struct GetThing<'a> {
+
//! #[serde(borrow)]
+
//! pub id: CowStr<'a>,
+
//! }
+
//! // Generates:
+
//! // - GetThingResponse struct
+
//! // - impl XrpcResp for GetThingResponse
+
//! // - impl XrpcRequest for GetThing
+
//! ```
use proc_macro::TokenStream;
-
use quote::quote;
-
use syn::{Data, DeriveInput, Fields, GenericParam, parse_macro_input};
+
use quote::{quote, format_ident};
+
use syn::{
+
Data, DeriveInput, Fields, GenericParam, parse_macro_input,
+
Attribute, Ident, LitStr,
+
};
/// Attribute macro that adds an `extra_data` field to structs to capture unknown fields
/// during deserialization.
···
}
}
}
+
+
/// Derive macro for `XrpcRequest` trait.
+
///
+
/// Automatically generates the response marker struct, `XrpcResp` impl, and `XrpcRequest` impl
+
/// for an XRPC endpoint. Optionally generates `XrpcEndpoint` impl for server-side usage.
+
///
+
/// # Attributes
+
///
+
/// - `nsid`: Required. The NSID string (e.g., "com.example.myMethod")
+
/// - `method`: Required. Either `Query` or `Procedure`
+
/// - `output`: Required. The output type (must support lifetime param if request does)
+
/// - `error`: Optional. Error type (defaults to `GenericError`)
+
/// - `server`: Optional flag. If present, generates `XrpcEndpoint` impl too
+
///
+
/// # Example
+
/// ```ignore
+
/// #[derive(Serialize, Deserialize, XrpcRequest)]
+
/// #[xrpc(
+
/// nsid = "com.example.getThing",
+
/// method = Query,
+
/// output = GetThingOutput,
+
/// )]
+
/// struct GetThing<'a> {
+
/// #[serde(borrow)]
+
/// pub id: CowStr<'a>,
+
/// }
+
/// ```
+
///
+
/// This generates:
+
/// - `GetThingResponse` struct implementing `XrpcResp`
+
/// - `XrpcRequest` impl for `GetThing`
+
/// - Optionally: `GetThingEndpoint` struct implementing `XrpcEndpoint` (if `server` flag present)
+
#[proc_macro_derive(XrpcRequest, attributes(xrpc))]
+
pub fn derive_xrpc_request(input: TokenStream) -> TokenStream {
+
let input = parse_macro_input!(input as DeriveInput);
+
+
match xrpc_request_impl(&input) {
+
Ok(tokens) => tokens.into(),
+
Err(e) => e.to_compile_error().into(),
+
}
+
}
+
+
fn xrpc_request_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
+
// Parse attributes
+
let attrs = parse_xrpc_attrs(&input.attrs)?;
+
+
let name = &input.ident;
+
let generics = &input.generics;
+
+
// Detect if type has lifetime parameter
+
let has_lifetime = generics.lifetimes().next().is_some();
+
let lifetime = if has_lifetime {
+
quote! { <'_> }
+
} else {
+
quote! {}
+
};
+
+
let nsid = &attrs.nsid;
+
let method = method_expr(&attrs.method);
+
let output_ty = &attrs.output;
+
let error_ty = attrs.error.as_ref()
+
.map(|e| quote! { #e })
+
.unwrap_or_else(|| quote! { ::jacquard_common::xrpc::GenericError });
+
+
// Generate response marker struct name
+
let response_name = format_ident!("{}Response", name);
+
+
// Build the impls
+
let mut output = quote! {
+
/// Response marker for #name
+
pub struct #response_name;
+
+
impl ::jacquard_common::xrpc::XrpcResp for #response_name {
+
const NSID: &'static str = #nsid;
+
const ENCODING: &'static str = "application/json";
+
type Output<'de> = #output_ty<'de>;
+
type Err<'de> = #error_ty<'de>;
+
}
+
+
impl #generics ::jacquard_common::xrpc::XrpcRequest for #name #lifetime {
+
const NSID: &'static str = #nsid;
+
const METHOD: ::jacquard_common::xrpc::XrpcMethod = #method;
+
type Response = #response_name;
+
}
+
};
+
+
// Optional server-side endpoint impl
+
if attrs.server {
+
let endpoint_name = format_ident!("{}Endpoint", name);
+
let path = format!("/xrpc/{}", nsid);
+
+
// Request type with or without lifetime
+
let request_type = if has_lifetime {
+
quote! { #name<'de> }
+
} else {
+
quote! { #name }
+
};
+
+
output.extend(quote! {
+
/// Endpoint marker for #name (server-side)
+
pub struct #endpoint_name;
+
+
impl ::jacquard_common::xrpc::XrpcEndpoint for #endpoint_name {
+
const PATH: &'static str = #path;
+
const METHOD: ::jacquard_common::xrpc::XrpcMethod = #method;
+
type Request<'de> = #request_type;
+
type Response = #response_name;
+
}
+
});
+
}
+
+
Ok(output)
+
}
+
+
struct XrpcAttrs {
+
nsid: String,
+
method: XrpcMethod,
+
output: syn::Type,
+
error: Option<syn::Type>,
+
server: bool,
+
}
+
+
enum XrpcMethod {
+
Query,
+
Procedure,
+
}
+
+
fn parse_xrpc_attrs(attrs: &[Attribute]) -> syn::Result<XrpcAttrs> {
+
let mut nsid = None;
+
let mut method = None;
+
let mut output = None;
+
let mut error = None;
+
let mut server = false;
+
+
for attr in attrs {
+
if !attr.path().is_ident("xrpc") {
+
continue;
+
}
+
+
attr.parse_nested_meta(|meta| {
+
if meta.path.is_ident("nsid") {
+
let value = meta.value()?;
+
let s: LitStr = value.parse()?;
+
nsid = Some(s.value());
+
Ok(())
+
} else if meta.path.is_ident("method") {
+
// Parse "method = Query" or "method = Procedure"
+
let _eq = meta.input.parse::<syn::Token![=]>()?;
+
let ident: Ident = meta.input.parse()?;
+
match ident.to_string().as_str() {
+
"Query" => {
+
method = Some(XrpcMethod::Query);
+
Ok(())
+
}
+
"Procedure" => {
+
// Always JSON, no custom encoding support
+
method = Some(XrpcMethod::Procedure);
+
Ok(())
+
}
+
other => Err(meta.error(format!("unknown method: {}, use Query or Procedure", other)))
+
}
+
} else if meta.path.is_ident("output") {
+
let value = meta.value()?;
+
output = Some(value.parse()?);
+
Ok(())
+
} else if meta.path.is_ident("error") {
+
let value = meta.value()?;
+
error = Some(value.parse()?);
+
Ok(())
+
} else if meta.path.is_ident("server") {
+
server = true;
+
Ok(())
+
} else {
+
Err(meta.error("unknown xrpc attribute"))
+
}
+
})?;
+
}
+
+
let nsid = nsid.ok_or_else(|| syn::Error::new(
+
proc_macro2::Span::call_site(),
+
"missing required `nsid` attribute"
+
))?;
+
let method = method.ok_or_else(|| syn::Error::new(
+
proc_macro2::Span::call_site(),
+
"missing required `method` attribute"
+
))?;
+
let output = output.ok_or_else(|| syn::Error::new(
+
proc_macro2::Span::call_site(),
+
"missing required `output` attribute"
+
))?;
+
+
Ok(XrpcAttrs {
+
nsid,
+
method,
+
output,
+
error,
+
server,
+
})
+
}
+
+
fn method_expr(method: &XrpcMethod) -> proc_macro2::TokenStream {
+
match method {
+
XrpcMethod::Query => quote! { ::jacquard_common::xrpc::XrpcMethod::Query },
+
XrpcMethod::Procedure => quote! { ::jacquard_common::xrpc::XrpcMethod::Procedure("application/json") },
+
}
+
}
+127
crates/jacquard/tests/xrpc_derive.rs
···
+
use jacquard::{CowStr, IntoStatic};
+
use jacquard_derive::XrpcRequest;
+
use serde::{Deserialize, Serialize};
+
+
// Test output type
+
#[derive(Serialize, Deserialize, IntoStatic)]
+
pub struct GetThingOutput<'a> {
+
#[serde(borrow)]
+
pub result: CowStr<'a>,
+
}
+
+
// Test basic query endpoint
+
#[derive(Serialize, Deserialize, XrpcRequest)]
+
#[xrpc(nsid = "com.example.getThing", method = Query, output = GetThingOutput)]
+
pub struct GetThing<'a> {
+
#[serde(borrow)]
+
pub id: CowStr<'a>,
+
}
+
+
// Test procedure endpoint
+
#[derive(Serialize, Deserialize, IntoStatic)]
+
pub struct CreateThingOutput<'a> {
+
#[serde(borrow)]
+
pub id: CowStr<'a>,
+
}
+
+
#[derive(Serialize, Deserialize, XrpcRequest)]
+
#[xrpc(
+
nsid = "com.example.createThing",
+
method = Procedure,
+
output = CreateThingOutput
+
)]
+
pub struct CreateThing<'a> {
+
#[serde(borrow)]
+
pub name: CowStr<'a>,
+
}
+
+
// Test with custom error type
+
#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+
#[error("Custom error")]
+
pub struct CustomError<'a> {
+
#[serde(borrow)]
+
pub message: CowStr<'a>,
+
}
+
+
impl jacquard::IntoStatic for CustomError<'_> {
+
type Output = CustomError<'static>;
+
fn into_static(self) -> Self::Output {
+
CustomError {
+
message: self.message.into_static(),
+
}
+
}
+
}
+
+
#[derive(Serialize, Deserialize, IntoStatic)]
+
pub struct DoThingOutput<'a> {
+
#[serde(borrow)]
+
pub status: CowStr<'a>,
+
}
+
+
#[derive(Serialize, Deserialize, XrpcRequest)]
+
#[xrpc(
+
nsid = "com.example.doThing",
+
method = Procedure,
+
output = DoThingOutput,
+
error = CustomError
+
)]
+
pub struct DoThing<'a> {
+
#[serde(borrow)]
+
pub param: CowStr<'a>,
+
}
+
+
// Test server-side endpoint generation
+
#[derive(Serialize, Deserialize, IntoStatic)]
+
pub struct ServerThingOutput<'a> {
+
#[serde(borrow)]
+
pub status: CowStr<'a>,
+
}
+
+
#[derive(Serialize, Deserialize, IntoStatic, XrpcRequest)]
+
#[xrpc(
+
nsid = "com.example.serverThing",
+
method = Query,
+
output = ServerThingOutput,
+
server
+
)]
+
pub struct ServerThing<'a> {
+
#[serde(borrow)]
+
pub query: CowStr<'a>,
+
}
+
+
#[test]
+
fn test_generated_response_markers() {
+
// Just verify the types exist and compile
+
let _: GetThingResponse;
+
let _: CreateThingResponse;
+
let _: DoThingResponse;
+
let _: ServerThingResponse;
+
}
+
+
#[test]
+
fn test_xrpc_request_impl() {
+
use jacquard::xrpc::{XrpcMethod, XrpcRequest};
+
+
// Query endpoint
+
assert_eq!(GetThing::NSID, "com.example.getThing");
+
assert!(matches!(GetThing::METHOD, XrpcMethod::Query));
+
+
// Procedure endpoint
+
assert_eq!(CreateThing::NSID, "com.example.createThing");
+
assert!(matches!(
+
CreateThing::METHOD,
+
XrpcMethod::Procedure("application/json")
+
));
+
}
+
+
#[test]
+
fn test_xrpc_endpoint_impl() {
+
use jacquard::xrpc::XrpcEndpoint;
+
+
// Server-side endpoint
+
assert_eq!(ServerThingEndpoint::PATH, "/xrpc/com.example.serverThing");
+
assert!(matches!(
+
ServerThingEndpoint::METHOD,
+
jacquard::xrpc::XrpcMethod::Query
+
));
+
}