inferadb_derive/
lib.rs

1//! Derive macros for the InferaDB SDK.
2//!
3//! This crate provides derive macros for implementing the `Resource` and `Subject`
4//! traits, enabling type-safe authorization operations.
5//!
6//! ## Usage
7//!
8//! Add to your `Cargo.toml`:
9//!
10//! ```toml
11//! [dependencies]
12//! inferadb = { version = "0.1", features = ["derive"] }
13//! ```
14//!
15//! ## Examples
16//!
17//! ```rust,ignore
18//! use inferadb::derive::{Resource, Subject};
19//!
20//! #[derive(Resource)]
21//! #[resource(type = "document")]
22//! struct Document {
23//!     #[resource(id)]
24//!     id: String,
25//!     title: String,
26//! }
27//!
28//! #[derive(Subject)]
29//! #[subject(type = "user")]
30//! struct User {
31//!     #[subject(id)]
32//!     id: String,
33//!     name: String,
34//! }
35//!
36//! // Now you can use these with the InferaDB SDK
37//! let doc = Document { id: "readme".into(), title: "README".into() };
38//! let user = User { id: "alice".into(), name: "Alice".into() };
39//!
40//! // Type-safe API
41//! assert_eq!(doc.as_resource_ref(), "document:readme");
42//! assert_eq!(user.as_subject_ref(), "user:alice");
43//! ```
44
45use proc_macro::TokenStream;
46use proc_macro2::TokenStream as TokenStream2;
47use quote::quote;
48use syn::{parse_macro_input, Data, DeriveInput, Error, Fields, Ident, LitStr, Result};
49
50/// Derive macro for implementing the `Resource` trait.
51///
52/// ## Attributes
53///
54/// - `#[resource(type = "...")]` - Required. The resource type name.
55/// - `#[resource(id)]` - Required on one field. The field containing the resource ID.
56///
57/// ## Example
58///
59/// ```rust,ignore
60/// #[derive(Resource)]
61/// #[resource(type = "document")]
62/// struct Document {
63///     #[resource(id)]
64///     id: String,
65///     title: String,
66/// }
67/// ```
68#[proc_macro_derive(Resource, attributes(resource))]
69pub fn derive_resource(input: TokenStream) -> TokenStream {
70    let input = parse_macro_input!(input as DeriveInput);
71    match derive_resource_impl(input) {
72        Ok(tokens) => tokens.into(),
73        Err(err) => err.to_compile_error().into(),
74    }
75}
76
77/// Derive macro for implementing the `Subject` trait.
78///
79/// ## Attributes
80///
81/// - `#[subject(type = "...")]` - Required. The subject type name.
82/// - `#[subject(id)]` - Required on one field. The field containing the subject ID.
83///
84/// ## Example
85///
86/// ```rust,ignore
87/// #[derive(Subject)]
88/// #[subject(type = "user")]
89/// struct User {
90///     #[subject(id)]
91///     id: String,
92///     name: String,
93/// }
94/// ```
95#[proc_macro_derive(Subject, attributes(subject))]
96pub fn derive_subject(input: TokenStream) -> TokenStream {
97    let input = parse_macro_input!(input as DeriveInput);
98    match derive_subject_impl(input) {
99        Ok(tokens) => tokens.into(),
100        Err(err) => err.to_compile_error().into(),
101    }
102}
103
104fn derive_resource_impl(input: DeriveInput) -> Result<TokenStream2> {
105    let name = &input.ident;
106    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
107
108    // Parse #[resource(type = "...")] from struct attributes
109    let resource_type = parse_type_attr(&input, "resource")?.ok_or_else(|| {
110        Error::new_spanned(&input, "missing #[resource(type = \"...\")] attribute")
111    })?;
112
113    // Find the field with #[resource(id)]
114    let id_field = find_id_field(&input.data, "resource")?;
115
116    Ok(quote! {
117        impl #impl_generics ::inferadb::Resource for #name #ty_generics #where_clause {
118            fn resource_type() -> &'static str {
119                #resource_type
120            }
121
122            fn resource_id(&self) -> &str {
123                &self.#id_field
124            }
125        }
126    })
127}
128
129fn derive_subject_impl(input: DeriveInput) -> Result<TokenStream2> {
130    let name = &input.ident;
131    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
132
133    // Parse #[subject(type = "...")] from struct attributes
134    let subject_type = parse_type_attr(&input, "subject")?.ok_or_else(|| {
135        Error::new_spanned(&input, "missing #[subject(type = \"...\")] attribute")
136    })?;
137
138    // Find the field with #[subject(id)]
139    let id_field = find_id_field(&input.data, "subject")?;
140
141    Ok(quote! {
142        impl #impl_generics ::inferadb::Subject for #name #ty_generics #where_clause {
143            fn subject_type() -> &'static str {
144                #subject_type
145            }
146
147            fn subject_id(&self) -> &str {
148                &self.#id_field
149            }
150        }
151    })
152}
153
154/// Parse the `type = "..."` value from `#[resource(...)]` or `#[subject(...)]` attributes.
155fn parse_type_attr(input: &DeriveInput, attr_name: &str) -> Result<Option<String>> {
156    for attr in &input.attrs {
157        if !attr.path().is_ident(attr_name) {
158            continue;
159        }
160
161        let mut type_value = None;
162        attr.parse_nested_meta(|meta| {
163            if meta.path.is_ident("type") {
164                let value: LitStr = meta.value()?.parse()?;
165                type_value = Some(value.value());
166            }
167            Ok(())
168        })?;
169
170        if type_value.is_some() {
171            return Ok(type_value);
172        }
173    }
174    Ok(None)
175}
176
177/// Find the field marked with `#[resource(id)]` or `#[subject(id)]`.
178fn find_id_field(data: &Data, attr_name: &str) -> Result<Ident> {
179    let fields = match data {
180        Data::Struct(data) => match &data.fields {
181            Fields::Named(fields) => &fields.named,
182            Fields::Unnamed(_) => {
183                return Err(Error::new(
184                    proc_macro2::Span::call_site(),
185                    "tuple structs are not supported",
186                ))
187            }
188            Fields::Unit => {
189                return Err(Error::new(
190                    proc_macro2::Span::call_site(),
191                    "unit structs are not supported",
192                ))
193            }
194        },
195        Data::Enum(_) => {
196            return Err(Error::new(
197                proc_macro2::Span::call_site(),
198                "enums are not supported",
199            ))
200        }
201        Data::Union(_) => {
202            return Err(Error::new(
203                proc_macro2::Span::call_site(),
204                "unions are not supported",
205            ))
206        }
207    };
208
209    for field in fields {
210        for attr in &field.attrs {
211            if !attr.path().is_ident(attr_name) {
212                continue;
213            }
214
215            // Check for #[resource(id)] or #[subject(id)]
216            let mut is_id_field = false;
217            let _ = attr.parse_nested_meta(|meta| {
218                if meta.path.is_ident("id") {
219                    is_id_field = true;
220                }
221                Ok(())
222            });
223
224            if is_id_field {
225                return field
226                    .ident
227                    .clone()
228                    .ok_or_else(|| Error::new_spanned(field, "expected named field"));
229            }
230        }
231    }
232
233    Err(Error::new(
234        proc_macro2::Span::call_site(),
235        format!("no field marked with #[{}(id)]", attr_name),
236    ))
237}
238
239#[cfg(test)]
240mod tests {
241    // Tests are in the integration tests since proc-macros can't be tested directly
242}