A better Rust ATProto crate

xrpc request derive macro

Orual aae802b5 c166b9be

+364 -5
+237 -5
crates/jacquard-derive/src/lib.rs
··· 1 1 //! # Derive macros for jacquard lexicon types 2 2 //! 3 - //! This crate provides attribute macros that the code generator uses to add lexicon-specific 4 - //! behavior to generated types. You'll rarely need to use these directly unless you're writing 5 - //! custom lexicon types by hand. However, deriving IntoStatic will likely be very useful. 3 + //! This crate provides attribute and derive macros for working with Jacquard types. 4 + //! The code generator uses `#[lexicon]` and `#[open_union]` to add lexicon-specific behavior. 5 + //! You'll use `#[derive(IntoStatic)]` frequently, and `#[derive(XrpcRequest)]` when defining 6 + //! custom XRPC endpoints. 6 7 //! 7 8 //! ## Macros 8 9 //! ··· 53 54 //! // fn into_static(self) -> Self::Output { ... } 54 55 //! // } 55 56 //! ``` 57 + //! 58 + //! ### `#[derive(XrpcRequest)]` 59 + //! 60 + //! Derives XRPC request traits for custom endpoints. Generates the response marker struct 61 + //! and implements `XrpcRequest` (and optionally `XrpcEndpoint` for server-side). 62 + //! 63 + //! ```ignore 64 + //! #[derive(Serialize, Deserialize, XrpcRequest)] 65 + //! #[xrpc( 66 + //! nsid = "com.example.getThing", 67 + //! method = Query, 68 + //! output = GetThingOutput, 69 + //! )] 70 + //! struct GetThing<'a> { 71 + //! #[serde(borrow)] 72 + //! pub id: CowStr<'a>, 73 + //! } 74 + //! // Generates: 75 + //! // - GetThingResponse struct 76 + //! // - impl XrpcResp for GetThingResponse 77 + //! // - impl XrpcRequest for GetThing 78 + //! ``` 56 79 57 80 use proc_macro::TokenStream; 58 - use quote::quote; 59 - use syn::{Data, DeriveInput, Fields, GenericParam, parse_macro_input}; 81 + use quote::{quote, format_ident}; 82 + use syn::{ 83 + Data, DeriveInput, Fields, GenericParam, parse_macro_input, 84 + Attribute, Ident, LitStr, 85 + }; 60 86 61 87 /// Attribute macro that adds an `extra_data` field to structs to capture unknown fields 62 88 /// during deserialization. ··· 335 361 } 336 362 } 337 363 } 364 + 365 + /// Derive macro for `XrpcRequest` trait. 366 + /// 367 + /// Automatically generates the response marker struct, `XrpcResp` impl, and `XrpcRequest` impl 368 + /// for an XRPC endpoint. Optionally generates `XrpcEndpoint` impl for server-side usage. 369 + /// 370 + /// # Attributes 371 + /// 372 + /// - `nsid`: Required. The NSID string (e.g., "com.example.myMethod") 373 + /// - `method`: Required. Either `Query` or `Procedure` 374 + /// - `output`: Required. The output type (must support lifetime param if request does) 375 + /// - `error`: Optional. Error type (defaults to `GenericError`) 376 + /// - `server`: Optional flag. If present, generates `XrpcEndpoint` impl too 377 + /// 378 + /// # Example 379 + /// ```ignore 380 + /// #[derive(Serialize, Deserialize, XrpcRequest)] 381 + /// #[xrpc( 382 + /// nsid = "com.example.getThing", 383 + /// method = Query, 384 + /// output = GetThingOutput, 385 + /// )] 386 + /// struct GetThing<'a> { 387 + /// #[serde(borrow)] 388 + /// pub id: CowStr<'a>, 389 + /// } 390 + /// ``` 391 + /// 392 + /// This generates: 393 + /// - `GetThingResponse` struct implementing `XrpcResp` 394 + /// - `XrpcRequest` impl for `GetThing` 395 + /// - Optionally: `GetThingEndpoint` struct implementing `XrpcEndpoint` (if `server` flag present) 396 + #[proc_macro_derive(XrpcRequest, attributes(xrpc))] 397 + pub fn derive_xrpc_request(input: TokenStream) -> TokenStream { 398 + let input = parse_macro_input!(input as DeriveInput); 399 + 400 + match xrpc_request_impl(&input) { 401 + Ok(tokens) => tokens.into(), 402 + Err(e) => e.to_compile_error().into(), 403 + } 404 + } 405 + 406 + fn xrpc_request_impl(input: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> { 407 + // Parse attributes 408 + let attrs = parse_xrpc_attrs(&input.attrs)?; 409 + 410 + let name = &input.ident; 411 + let generics = &input.generics; 412 + 413 + // Detect if type has lifetime parameter 414 + let has_lifetime = generics.lifetimes().next().is_some(); 415 + let lifetime = if has_lifetime { 416 + quote! { <'_> } 417 + } else { 418 + quote! {} 419 + }; 420 + 421 + let nsid = &attrs.nsid; 422 + let method = method_expr(&attrs.method); 423 + let output_ty = &attrs.output; 424 + let error_ty = attrs.error.as_ref() 425 + .map(|e| quote! { #e }) 426 + .unwrap_or_else(|| quote! { ::jacquard_common::xrpc::GenericError }); 427 + 428 + // Generate response marker struct name 429 + let response_name = format_ident!("{}Response", name); 430 + 431 + // Build the impls 432 + let mut output = quote! { 433 + /// Response marker for #name 434 + pub struct #response_name; 435 + 436 + impl ::jacquard_common::xrpc::XrpcResp for #response_name { 437 + const NSID: &'static str = #nsid; 438 + const ENCODING: &'static str = "application/json"; 439 + type Output<'de> = #output_ty<'de>; 440 + type Err<'de> = #error_ty<'de>; 441 + } 442 + 443 + impl #generics ::jacquard_common::xrpc::XrpcRequest for #name #lifetime { 444 + const NSID: &'static str = #nsid; 445 + const METHOD: ::jacquard_common::xrpc::XrpcMethod = #method; 446 + type Response = #response_name; 447 + } 448 + }; 449 + 450 + // Optional server-side endpoint impl 451 + if attrs.server { 452 + let endpoint_name = format_ident!("{}Endpoint", name); 453 + let path = format!("/xrpc/{}", nsid); 454 + 455 + // Request type with or without lifetime 456 + let request_type = if has_lifetime { 457 + quote! { #name<'de> } 458 + } else { 459 + quote! { #name } 460 + }; 461 + 462 + output.extend(quote! { 463 + /// Endpoint marker for #name (server-side) 464 + pub struct #endpoint_name; 465 + 466 + impl ::jacquard_common::xrpc::XrpcEndpoint for #endpoint_name { 467 + const PATH: &'static str = #path; 468 + const METHOD: ::jacquard_common::xrpc::XrpcMethod = #method; 469 + type Request<'de> = #request_type; 470 + type Response = #response_name; 471 + } 472 + }); 473 + } 474 + 475 + Ok(output) 476 + } 477 + 478 + struct XrpcAttrs { 479 + nsid: String, 480 + method: XrpcMethod, 481 + output: syn::Type, 482 + error: Option<syn::Type>, 483 + server: bool, 484 + } 485 + 486 + enum XrpcMethod { 487 + Query, 488 + Procedure, 489 + } 490 + 491 + fn parse_xrpc_attrs(attrs: &[Attribute]) -> syn::Result<XrpcAttrs> { 492 + let mut nsid = None; 493 + let mut method = None; 494 + let mut output = None; 495 + let mut error = None; 496 + let mut server = false; 497 + 498 + for attr in attrs { 499 + if !attr.path().is_ident("xrpc") { 500 + continue; 501 + } 502 + 503 + attr.parse_nested_meta(|meta| { 504 + if meta.path.is_ident("nsid") { 505 + let value = meta.value()?; 506 + let s: LitStr = value.parse()?; 507 + nsid = Some(s.value()); 508 + Ok(()) 509 + } else if meta.path.is_ident("method") { 510 + // Parse "method = Query" or "method = Procedure" 511 + let _eq = meta.input.parse::<syn::Token![=]>()?; 512 + let ident: Ident = meta.input.parse()?; 513 + match ident.to_string().as_str() { 514 + "Query" => { 515 + method = Some(XrpcMethod::Query); 516 + Ok(()) 517 + } 518 + "Procedure" => { 519 + // Always JSON, no custom encoding support 520 + method = Some(XrpcMethod::Procedure); 521 + Ok(()) 522 + } 523 + other => Err(meta.error(format!("unknown method: {}, use Query or Procedure", other))) 524 + } 525 + } else if meta.path.is_ident("output") { 526 + let value = meta.value()?; 527 + output = Some(value.parse()?); 528 + Ok(()) 529 + } else if meta.path.is_ident("error") { 530 + let value = meta.value()?; 531 + error = Some(value.parse()?); 532 + Ok(()) 533 + } else if meta.path.is_ident("server") { 534 + server = true; 535 + Ok(()) 536 + } else { 537 + Err(meta.error("unknown xrpc attribute")) 538 + } 539 + })?; 540 + } 541 + 542 + let nsid = nsid.ok_or_else(|| syn::Error::new( 543 + proc_macro2::Span::call_site(), 544 + "missing required `nsid` attribute" 545 + ))?; 546 + let method = method.ok_or_else(|| syn::Error::new( 547 + proc_macro2::Span::call_site(), 548 + "missing required `method` attribute" 549 + ))?; 550 + let output = output.ok_or_else(|| syn::Error::new( 551 + proc_macro2::Span::call_site(), 552 + "missing required `output` attribute" 553 + ))?; 554 + 555 + Ok(XrpcAttrs { 556 + nsid, 557 + method, 558 + output, 559 + error, 560 + server, 561 + }) 562 + } 563 + 564 + fn method_expr(method: &XrpcMethod) -> proc_macro2::TokenStream { 565 + match method { 566 + XrpcMethod::Query => quote! { ::jacquard_common::xrpc::XrpcMethod::Query }, 567 + XrpcMethod::Procedure => quote! { ::jacquard_common::xrpc::XrpcMethod::Procedure("application/json") }, 568 + } 569 + }
+127
crates/jacquard/tests/xrpc_derive.rs
··· 1 + use jacquard::{CowStr, IntoStatic}; 2 + use jacquard_derive::XrpcRequest; 3 + use serde::{Deserialize, Serialize}; 4 + 5 + // Test output type 6 + #[derive(Serialize, Deserialize, IntoStatic)] 7 + pub struct GetThingOutput<'a> { 8 + #[serde(borrow)] 9 + pub result: CowStr<'a>, 10 + } 11 + 12 + // Test basic query endpoint 13 + #[derive(Serialize, Deserialize, XrpcRequest)] 14 + #[xrpc(nsid = "com.example.getThing", method = Query, output = GetThingOutput)] 15 + pub struct GetThing<'a> { 16 + #[serde(borrow)] 17 + pub id: CowStr<'a>, 18 + } 19 + 20 + // Test procedure endpoint 21 + #[derive(Serialize, Deserialize, IntoStatic)] 22 + pub struct CreateThingOutput<'a> { 23 + #[serde(borrow)] 24 + pub id: CowStr<'a>, 25 + } 26 + 27 + #[derive(Serialize, Deserialize, XrpcRequest)] 28 + #[xrpc( 29 + nsid = "com.example.createThing", 30 + method = Procedure, 31 + output = CreateThingOutput 32 + )] 33 + pub struct CreateThing<'a> { 34 + #[serde(borrow)] 35 + pub name: CowStr<'a>, 36 + } 37 + 38 + // Test with custom error type 39 + #[derive(Serialize, Deserialize, Debug, thiserror::Error)] 40 + #[error("Custom error")] 41 + pub struct CustomError<'a> { 42 + #[serde(borrow)] 43 + pub message: CowStr<'a>, 44 + } 45 + 46 + impl jacquard::IntoStatic for CustomError<'_> { 47 + type Output = CustomError<'static>; 48 + fn into_static(self) -> Self::Output { 49 + CustomError { 50 + message: self.message.into_static(), 51 + } 52 + } 53 + } 54 + 55 + #[derive(Serialize, Deserialize, IntoStatic)] 56 + pub struct DoThingOutput<'a> { 57 + #[serde(borrow)] 58 + pub status: CowStr<'a>, 59 + } 60 + 61 + #[derive(Serialize, Deserialize, XrpcRequest)] 62 + #[xrpc( 63 + nsid = "com.example.doThing", 64 + method = Procedure, 65 + output = DoThingOutput, 66 + error = CustomError 67 + )] 68 + pub struct DoThing<'a> { 69 + #[serde(borrow)] 70 + pub param: CowStr<'a>, 71 + } 72 + 73 + // Test server-side endpoint generation 74 + #[derive(Serialize, Deserialize, IntoStatic)] 75 + pub struct ServerThingOutput<'a> { 76 + #[serde(borrow)] 77 + pub status: CowStr<'a>, 78 + } 79 + 80 + #[derive(Serialize, Deserialize, IntoStatic, XrpcRequest)] 81 + #[xrpc( 82 + nsid = "com.example.serverThing", 83 + method = Query, 84 + output = ServerThingOutput, 85 + server 86 + )] 87 + pub struct ServerThing<'a> { 88 + #[serde(borrow)] 89 + pub query: CowStr<'a>, 90 + } 91 + 92 + #[test] 93 + fn test_generated_response_markers() { 94 + // Just verify the types exist and compile 95 + let _: GetThingResponse; 96 + let _: CreateThingResponse; 97 + let _: DoThingResponse; 98 + let _: ServerThingResponse; 99 + } 100 + 101 + #[test] 102 + fn test_xrpc_request_impl() { 103 + use jacquard::xrpc::{XrpcMethod, XrpcRequest}; 104 + 105 + // Query endpoint 106 + assert_eq!(GetThing::NSID, "com.example.getThing"); 107 + assert!(matches!(GetThing::METHOD, XrpcMethod::Query)); 108 + 109 + // Procedure endpoint 110 + assert_eq!(CreateThing::NSID, "com.example.createThing"); 111 + assert!(matches!( 112 + CreateThing::METHOD, 113 + XrpcMethod::Procedure("application/json") 114 + )); 115 + } 116 + 117 + #[test] 118 + fn test_xrpc_endpoint_impl() { 119 + use jacquard::xrpc::XrpcEndpoint; 120 + 121 + // Server-side endpoint 122 + assert_eq!(ServerThingEndpoint::PATH, "/xrpc/com.example.serverThing"); 123 + assert!(matches!( 124 + ServerThingEndpoint::METHOD, 125 + jacquard::xrpc::XrpcMethod::Query 126 + )); 127 + }