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