Skip to main content

ferro_macros/
lib.rs

1//! Procedural macros for the Ferro framework
2//!
3//! This crate provides compile-time validated macros for:
4//! - Inertia.js responses with component validation
5//! - Named route redirects with route validation
6//! - Service auto-registration
7//! - Handler attribute for controller methods
8//! - FormRequest for validated request data
9//! - Jest-like testing with describe! and test! macros
10
11use proc_macro::TokenStream;
12
13mod action;
14mod authenticatable;
15mod describe;
16mod domain_error;
17mod ferro_test;
18mod handler;
19mod inertia;
20mod injectable;
21mod model;
22mod redirect;
23mod request;
24mod resource;
25mod resource_get;
26mod resource_post;
27mod service;
28mod test_macro;
29mod utils;
30mod validate;
31
32/// Derive macro for generating `Serialize` implementation for Inertia props
33///
34/// # Example
35///
36/// ```rust,ignore
37/// #[derive(InertiaProps)]
38/// struct HomeProps {
39///     title: String,
40///     user: User,
41/// }
42/// ```
43#[proc_macro_derive(InertiaProps, attributes(inertia))]
44pub fn derive_inertia_props(input: TokenStream) -> TokenStream {
45    inertia::derive_inertia_props_impl(input)
46}
47
48/// Create an Inertia response with compile-time component validation
49///
50/// # Examples
51///
52/// ## With typed struct (recommended for type safety):
53/// ```rust,ignore
54/// #[derive(InertiaProps)]
55/// struct HomeProps {
56///     title: String,
57///     user: User,
58/// }
59///
60/// inertia_response!("Home", HomeProps { title: "Welcome".into(), user })
61/// ```
62///
63/// ## With JSON-like syntax (for quick prototyping):
64/// ```rust,ignore
65/// inertia_response!("Dashboard", { "user": { "name": "John" } })
66/// ```
67///
68/// This macro validates that the component file exists at compile time.
69/// If `frontend/src/pages/Dashboard.tsx` doesn't exist, you'll get a compile error.
70#[proc_macro]
71pub fn inertia_response(input: TokenStream) -> TokenStream {
72    inertia::inertia_response_impl(input)
73}
74
75/// Create a redirect to a path or named route
76///
77/// # Examples
78///
79/// ```rust,ignore
80/// // Path redirect (starts with /)
81/// redirect!("/dashboard").into()
82///
83/// // Named route redirect
84/// redirect!("users.index").into()
85///
86/// // Redirect with route parameters
87/// redirect!("users.show").with("id", "42").into()
88///
89/// // Redirect with query parameters
90/// redirect!("users.index").query("page", "1").into()
91/// ```
92///
93/// For named routes, this macro validates that the route exists at compile time.
94/// Path redirects (starting with `/`) bypass validation and redirect directly.
95#[proc_macro]
96pub fn redirect(input: TokenStream) -> TokenStream {
97    redirect::redirect_impl(input)
98}
99
100/// Mark a trait as a service for the App container
101///
102/// This attribute macro automatically adds `Send + Sync + 'static` bounds
103/// to your trait, making it suitable for use with the dependency injection
104/// container.
105///
106/// # Example
107///
108/// ```rust,ignore
109/// use ferro::service;
110///
111/// #[service]
112/// pub trait HttpClient {
113///     async fn get(&self, url: &str) -> Result<String, Error>;
114/// }
115///
116/// // This expands to:
117/// pub trait HttpClient: Send + Sync + 'static {
118///     async fn get(&self, url: &str) -> Result<String, Error>;
119/// }
120/// ```
121///
122/// Then you can use it with the App container:
123///
124/// ```rust,ignore
125/// // Register
126/// App::bind::<dyn HttpClient>(Arc::new(RealHttpClient::new()));
127///
128/// // Resolve
129/// let client: Arc<dyn HttpClient> = App::make::<dyn HttpClient>().unwrap();
130/// ```
131#[proc_macro_attribute]
132pub fn service(attr: TokenStream, input: TokenStream) -> TokenStream {
133    service::service_impl(attr, input)
134}
135
136/// Attribute macro to auto-register a concrete type as a singleton
137///
138/// This macro automatically:
139/// 1. Derives `Default` and `Clone` for the struct
140/// 2. Registers it as a singleton in the App container at startup
141///
142/// # Example
143///
144/// ```rust,ignore
145/// use ferro::injectable;
146///
147/// #[injectable]
148/// pub struct AppState {
149///     pub counter: u32,
150/// }
151///
152/// // Automatically registered at startup
153/// // Resolve via:
154/// let state: AppState = App::get().unwrap();
155/// ```
156#[proc_macro_attribute]
157pub fn injectable(_attr: TokenStream, input: TokenStream) -> TokenStream {
158    injectable::injectable_impl(input)
159}
160
161/// Define a domain error with automatic HTTP response conversion
162///
163/// This macro automatically:
164/// 1. Derives `Debug` and `Clone` for the type
165/// 2. Implements `Display`, `Error`, and `HttpError` traits
166/// 3. Implements `From<T> for FrameworkError` for seamless `?` usage
167///
168/// # Attributes
169///
170/// - `status`: HTTP status code (default: 500)
171/// - `message`: Error message for Display (default: struct name converted to sentence)
172///
173/// # Example
174///
175/// ```rust,ignore
176/// use ferro::domain_error;
177///
178/// #[domain_error(status = 404, message = "User not found")]
179/// pub struct UserNotFoundError {
180///     pub user_id: i32,
181/// }
182///
183/// // Usage in controller - just use ? operator
184/// pub async fn get_user(id: i32) -> Result<User, FrameworkError> {
185///     users.find(id).ok_or(UserNotFoundError { user_id: id })?
186/// }
187/// ```
188#[proc_macro_attribute]
189pub fn domain_error(attr: TokenStream, input: TokenStream) -> TokenStream {
190    domain_error::domain_error_impl(attr, input)
191}
192
193/// Attribute macro for controller handler methods
194///
195/// Transforms handler functions to automatically extract typed parameters
196/// from HTTP requests using the `FromRequest` trait.
197///
198/// # Examples
199///
200/// ## With Request parameter:
201/// ```rust,ignore
202/// use ferro::{handler, Request, Response, json_response};
203///
204/// #[handler]
205/// pub async fn index(req: Request) -> Response {
206///     json_response!({ "message": "Hello" })
207/// }
208/// ```
209///
210/// ## With FormRequest parameter:
211/// ```rust,ignore
212/// use ferro::{handler, Response, json_response, request};
213///
214/// #[request]
215/// pub struct CreateUserRequest {
216///     #[validate(email)]
217///     pub email: String,
218/// }
219///
220/// #[handler]
221/// pub async fn store(form: CreateUserRequest) -> Response {
222///     // `form` is already validated - returns 422 if invalid
223///     json_response!({ "email": form.email })
224/// }
225/// ```
226///
227/// ## Without parameters:
228/// ```rust,ignore
229/// #[handler]
230/// pub async fn health_check() -> Response {
231///     json_response!({ "status": "ok" })
232/// }
233/// ```
234#[proc_macro_attribute]
235pub fn handler(attr: TokenStream, input: TokenStream) -> TokenStream {
236    handler::handler_impl(attr, input)
237}
238
239/// Attribute macro for POST-style action handlers that mutate state and redirect.
240///
241/// Transforms an async function returning `ActionResult` into a
242/// `Response`-returning handler. On `Ok(())` emits a 303 redirect to
243/// `redirect_to`; on `Err(ActionError)` emits a 303 redirect to `redirect_to`
244/// (or `err.redirect_override` if set and same-origin) with a flash payload
245/// and back-compat `?error=...&msg=...` query parameters.
246///
247/// # Required attributes
248///
249/// - `redirect_to = "<path>"` — the default 303 target on success.
250///
251/// # Optional attributes
252///
253/// - `method = "<METHOD>"` — HTTP method hint (default `"POST"`).
254///
255/// # Example
256///
257/// ```rust,ignore
258/// use ferro::{action, ActionError, ActionResult, Request};
259///
260/// #[action(redirect_to = "/dashboard/pagine")]
261/// pub async fn publish_by_id(req: Request) -> ActionResult {
262///     let id: i64 = req.param("id")?.parse()?;
263///     publish_page(id).await?;
264///     Ok(())
265/// }
266/// ```
267#[proc_macro_attribute]
268pub fn action(attr: TokenStream, input: TokenStream) -> TokenStream {
269    action::action_impl(attr, input)
270}
271
272/// Derive macro for FormRequest trait
273///
274/// Generates the `FormRequest` trait implementation for a struct.
275/// The struct must also derive `serde::Deserialize` and `validator::Validate`.
276///
277/// For the cleanest DX, use the `#[request]` attribute macro instead,
278/// which handles all derives automatically.
279///
280/// # Example
281///
282/// ```rust,ignore
283/// use ferro::{FormRequest, Deserialize, Validate};
284///
285/// #[derive(Deserialize, Validate, FormRequest)]
286/// pub struct CreateUserRequest {
287///     #[validate(email)]
288///     pub email: String,
289///
290///     #[validate(length(min = 8))]
291///     pub password: String,
292/// }
293/// ```
294#[proc_macro_derive(FormRequest)]
295pub fn derive_form_request(input: TokenStream) -> TokenStream {
296    request::derive_request_impl(input)
297}
298
299/// Attribute macro for clean request data definition
300///
301/// This is the recommended way to define validated request types.
302/// It automatically adds the necessary derives and generates the trait impl.
303///
304/// Works with both:
305/// - `application/json` - JSON request bodies
306/// - `application/x-www-form-urlencoded` - HTML form submissions
307///
308/// # Example
309///
310/// ```rust,ignore
311/// use ferro::request;
312///
313/// #[request]
314/// pub struct CreateUserRequest {
315///     #[validate(email)]
316///     pub email: String,
317///
318///     #[validate(length(min = 8))]
319///     pub password: String,
320/// }
321///
322/// // This can now be used directly in handlers:
323/// #[handler]
324/// pub async fn store(form: CreateUserRequest) -> Response {
325///     // Automatically validated - returns 422 with errors if invalid
326///     json_response!({ "email": form.email })
327/// }
328/// ```
329#[proc_macro_attribute]
330pub fn request(attr: TokenStream, input: TokenStream) -> TokenStream {
331    request::request_attr_impl(attr, input)
332}
333
334/// Attribute macro for database-enabled tests
335///
336/// This macro simplifies writing tests that need database access by automatically
337/// setting up an in-memory SQLite database with migrations applied.
338///
339/// By default, it uses `crate::migrations::Migrator` as the migrator type,
340/// following Ferro's convention for migration location.
341///
342/// # Examples
343///
344/// ## Basic usage (recommended):
345/// ```rust,ignore
346/// use ferro::ferro_test;
347/// use ferro::testing::TestDatabase;
348///
349/// #[ferro_test]
350/// async fn test_user_creation(db: TestDatabase) {
351///     // db is an in-memory SQLite database with all migrations applied
352///     // Any code using DB::connection() will use this test database
353///     let action = CreateUserAction::new();
354///     let user = action.execute("test@example.com").await.unwrap();
355///     assert!(user.id > 0);
356/// }
357/// ```
358///
359/// ## Without TestDatabase parameter:
360/// ```rust,ignore
361/// #[ferro_test]
362/// async fn test_action_without_direct_db_access() {
363///     // Database is set up but not directly accessed
364///     // Actions using DB::connection() still work
365///     let action = MyAction::new();
366///     action.execute().await.unwrap();
367/// }
368/// ```
369///
370/// ## With custom migrator:
371/// ```rust,ignore
372/// #[ferro_test(migrator = my_crate::CustomMigrator)]
373/// async fn test_with_custom_migrator(db: TestDatabase) {
374///     // Uses custom migrator instead of default
375/// }
376/// ```
377#[proc_macro_attribute]
378pub fn ferro_test(attr: TokenStream, input: TokenStream) -> TokenStream {
379    ferro_test::ferro_test_impl(attr, input)
380}
381
382/// Group related tests with a descriptive name
383///
384/// Creates a module containing related tests, similar to Jest's describe blocks.
385/// Supports nesting for hierarchical test organization.
386///
387/// # Example
388///
389/// ```rust,ignore
390/// use ferro::{describe, test, expect};
391/// use ferro::testing::TestDatabase;
392///
393/// describe!("ListTodosAction", {
394///     test!("returns empty list when no todos exist", async fn(db: TestDatabase) {
395///         let action = ListTodosAction::new();
396///         let todos = action.execute().await.unwrap();
397///         expect!(todos).to_be_empty();
398///     });
399///
400///     // Nested describe for grouping related tests
401///     describe!("with pagination", {
402///         test!("returns first page", async fn(db: TestDatabase) {
403///             // ...
404///         });
405///     });
406/// });
407/// ```
408#[proc_macro]
409pub fn describe(input: TokenStream) -> TokenStream {
410    describe::describe_impl(input)
411}
412
413/// Define an individual test case with a descriptive name
414///
415/// Creates a test function with optional TestDatabase parameter.
416/// The test name is displayed in failure output for easy identification.
417///
418/// # Examples
419///
420/// ## Async test with database
421/// ```rust,ignore
422/// test!("creates a user", async fn(db: TestDatabase) {
423///     let user = CreateUserAction::new().execute("test@example.com").await.unwrap();
424///     expect!(user.email).to_equal("test@example.com".to_string());
425/// });
426/// ```
427///
428/// ## Async test without database
429/// ```rust,ignore
430/// test!("calculates sum", async fn() {
431///     let result = calculate_sum(1, 2).await;
432///     expect!(result).to_equal(3);
433/// });
434/// ```
435///
436/// ## Sync test
437/// ```rust,ignore
438/// test!("adds numbers", fn() {
439///     expect!(1 + 1).to_equal(2);
440/// });
441/// ```
442///
443/// On failure, the test name is shown:
444/// ```text
445/// Test: "creates a user"
446///   at src/actions/user_action.rs:25
447///
448///   expect!(actual).to_equal(expected)
449///
450///   Expected: "test@example.com"
451///   Received: "wrong@email.com"
452/// ```
453#[proc_macro]
454pub fn test(input: TokenStream) -> TokenStream {
455    test_macro::test_impl(input)
456}
457
458/// Derive macro for reducing SeaORM model boilerplate
459///
460/// Generates create builder, update builder, and convenience methods for Ferro models.
461/// Apply to a SeaORM Model struct to get:
462/// - `Model::query()` - Start a new QueryBuilder
463/// - `Model::create()` - Get a builder for inserting new records
464/// - `model.update()` - Get an UpdateBuilder for selective field updates
465/// - `model.delete()` - Delete the record
466///
467/// # Example
468///
469/// ```rust,ignore
470/// use ferro::FerroModel;
471/// use sea_orm::entity::prelude::*;
472///
473/// #[derive(Clone, Debug, DeriveEntityModel, FerroModel)]
474/// #[sea_orm(table_name = "users")]
475/// pub struct Model {
476///     #[sea_orm(primary_key)]
477///     pub id: i32,
478///     pub name: String,
479///     pub email: String,
480///     pub bio: Option<String>,
481/// }
482///
483/// // Create a new record
484/// let user = User::create()
485///     .set_name("John")
486///     .set_email("john@example.com")
487///     .insert()
488///     .await?;
489///
490/// // Update specific fields only (unchanged fields are not sent to DB)
491/// let updated = user
492///     .update()
493///     .set_name("John Doe")
494///     .set_bio("Developer")
495///     .save()
496///     .await?;
497///
498/// // Clear an optional field to NULL
499/// let updated = updated
500///     .update()
501///     .clear_bio()
502///     .save()
503///     .await?;
504///
505/// // Query records
506/// let users = User::query()
507///     .filter(Column::Name.contains("John"))
508///     .all()
509///     .await?;
510/// ```
511#[proc_macro_derive(FerroModel)]
512pub fn derive_ferro_model(input: TokenStream) -> TokenStream {
513    model::ferro_model_impl(input)
514}
515
516/// Derive the `Authenticatable` trait so `Auth::user_as::<T>()` works.
517///
518/// Generates `auth_identifier` (`self.id as i64`), `auth_identifier_name`, and
519/// `as_any` for a struct with an integer `id` field. Override the field with
520/// `#[auth(id = "user_id")]`.
521///
522/// ```rust,ignore
523/// #[derive(Clone, DeriveEntityModel, Authenticatable)]
524/// pub struct Model { #[sea_orm(primary_key)] pub id: i32, /* … */ }
525/// ```
526#[proc_macro_derive(Authenticatable, attributes(auth))]
527pub fn derive_authenticatable(input: TokenStream) -> TokenStream {
528    authenticatable::derive_authenticatable_impl(input)
529}
530
531/// Derive macro for declarative struct validation using Ferro's rules
532///
533/// Generates `Validatable` trait implementation from field attributes.
534/// Validation rules are co-located with the struct definition.
535///
536/// This uses Ferro's Laravel-style validation rules (required(), email(), etc.)
537/// rather than the external `validator` crate.
538///
539/// # Example
540///
541/// ```rust,ignore
542/// use ferro::ValidateRules;
543///
544/// #[derive(ValidateRules)]
545/// struct CreateUserRequest {
546///     #[rule(required, email)]
547///     email: String,
548///
549///     #[rule(required, min(8))]
550///     password: String,
551///
552///     #[rule(required, integer, min(18))]
553///     age: Option<i32>,
554/// }
555///
556/// // Usage
557/// let request = CreateUserRequest { ... };
558/// request.validate()?;
559/// ```
560#[proc_macro_derive(ValidateRules, attributes(rule))]
561pub fn derive_validate_rules(input: TokenStream) -> TokenStream {
562    validate::validate_impl(input)
563}
564
565/// Derive macro for generating `Resource` trait implementation from struct annotations
566///
567/// Supports struct-level and field-level `#[resource(...)]` attributes:
568///
569/// - `#[resource(model = "path::to::Model")]` (struct-level) — generates `From<Model>` impl
570/// - `#[resource(rename = "new_name")]` (field-level) — use a different key in JSON output
571/// - `#[resource(skip)]` (field-level) — exclude field from JSON output
572///
573/// # Example
574///
575/// ```rust,ignore
576/// use ferro::ApiResource;
577///
578/// #[derive(ApiResource)]
579/// #[resource(model = "entities::users::Model")]
580/// pub struct UserResource {
581///     pub id: i32,
582///     pub name: String,
583///     #[resource(rename = "member_since")]
584///     pub created_at: String,
585///     #[resource(skip)]
586///     pub password_hash: String,
587/// }
588/// ```
589#[proc_macro_derive(ApiResource, attributes(resource))]
590pub fn derive_api_resource(input: TokenStream) -> TokenStream {
591    resource::api_resource_impl(input)
592}
593
594/// Attribute macro for GET handlers displaying a single tenant-scoped resource.
595///
596/// Folds id-extraction + tenant resolution + tenant-scoped lookup + 404-on-miss
597/// into a single attribute. Tenant and resource remain real typed function
598/// parameters; the user body moves to a named inner fn `__<name>_inner`.
599///
600/// # Required arguments
601///
602/// - First positional arg: the resource type implementing `TenantScoped`, e.g. `Customer`.
603///
604/// # Optional arguments
605///
606/// - `on_miss = "/url"` — redirect target on lookup miss; omitted → 404.
607///   Supports `{id}` placeholder (substituted with the extracted resource id).
608/// - `tenant = "expr"` — escape-hatch Rust expression for tenant resolution (default: `current_tenant()`).
609/// - `find = "path::fn"` — override the lookup function (default: `TenantScoped::find_for_tenant`).
610///
611/// # Example
612///
613/// ```ignore
614/// use ferro::{resource_get, Response, Request, TenantContext};
615///
616/// #[resource_get(Customer, on_miss = "/dashboard/clienti")]
617/// pub async fn edit(req: &mut Request, tenant: &TenantContext, customer: &Customer) -> Response {
618///     // customer is guaranteed to exist and belong to tenant
619///     Ok(ferro::HttpResponse::new())
620/// }
621/// ```
622///
623/// # Expands to (abridged)
624///
625/// The attribute is equivalent to the following expansion (shown via `cargo expand`):
626///
627/// ```ignore
628/// // Generated outer fn — accepts a raw Request, performs prelude, delegates.
629/// pub async fn edit(__ferro_req: ::ferro::Request) -> ::ferro::Response {
630///     let mut __ferro_req = __ferro_req;
631///     let __resource_id: <Customer as ::ferro::TenantScoped>::Id =
632///         __ferro_req.param_as("id").map_err(|_| ::ferro::HttpResponse::new().status(400))?;
633///     let __tenant: ::ferro::TenantContext = ::ferro::current_tenant()
634///         .ok_or_else(|| ::ferro::HttpResponse::new().status(400))?;
635///     let __resource_opt = <Customer as ::ferro::TenantScoped>::find_for_tenant(
636///         __resource_id, __tenant.id,
637///     ).await.map_err(|_| ::ferro::HttpResponse::new().status(500))?;
638///     let __resource = match __resource_opt {
639///         Some(r) => r,
640///         None => return Err(::ferro::HttpResponse::new().status(302).header("Location", "/dashboard/clienti")),
641///     };
642///     __edit_inner(&mut __ferro_req, &__tenant, &__resource).await
643/// }
644///
645/// // Named inner fn — tenant and resource are real typed parameters; IDE jump-to-def works.
646/// async fn __edit_inner(
647///     req: &mut ::ferro::Request,
648///     tenant: &::ferro::TenantContext,
649///     customer: &Customer,
650/// ) -> ::ferro::Response {
651///     // user body here
652///     Ok(ferro::HttpResponse::new())
653/// }
654/// ```
655///
656/// # Security
657///
658/// The generated lookup always calls `TenantScoped::find_for_tenant(id, tenant.id)` —
659/// cross-tenant reads are structurally impossible through this macro. T-212-01.
660#[proc_macro_attribute]
661pub fn resource_get(attr: TokenStream, input: TokenStream) -> TokenStream {
662    resource_get::resource_get_impl(attr, input)
663}
664
665/// Attribute macro for POST handlers mutating a single tenant-scoped resource.
666///
667/// Folds the same prelude as `#[resource_get]` plus the validation-failure
668/// redirect envelope (via `handle_action_result`). Requires `redirect_to`.
669///
670/// # Required arguments
671///
672/// - First positional arg: the resource type implementing `TenantScoped`.
673/// - `redirect_to = "/url"` — default 303 redirect on success (and error fallback).
674///
675/// # Optional arguments
676///
677/// - `form_url = "/url/{id}/edit"` — the edit form URL, synthesized from extracted
678///   path params; injected as `__form_url: &str` in the inner fn body.
679/// - `on_miss = "/url"` — 303 redirect on lookup miss; omitted → 404 `HttpResponse`.
680/// - `tenant = "expr"` — escape-hatch expression for tenant resolution.
681/// - `find = "path::fn"` — override the lookup function.
682///
683/// # Example
684///
685/// ```ignore
686/// use ferro::{resource_post, ActionResult, Request, TenantContext};
687///
688/// #[resource_post(Customer,
689///     redirect_to = "/dashboard/clienti",
690///     form_url = "/dashboard/clienti/{id}/modifica")]
691/// pub async fn save(req: &mut Request, tenant: &TenantContext, customer: &Customer) -> ActionResult {
692///     let data = serde_json::json!({ "name": "test" });
693///     ferro::Validator::new(&data)
694///         .rules("name", ferro::rules![ferro::required()])
695///         .validate_or_redirect(__form_url)?;
696///     Ok(())
697/// }
698/// ```
699///
700/// # Expands to (abridged)
701///
702/// The attribute is equivalent to the following expansion (shown via `cargo expand`):
703///
704/// ```ignore
705/// // Generated outer fn — same prelude as resource_get, plus form_url synthesis and
706/// // validation-redirect envelope via handle_action_result.
707/// pub async fn save(__ferro_req: ::ferro::Request) -> ::ferro::Response {
708///     let mut __ferro_req = __ferro_req;
709///     let __resource_id: <Customer as ::ferro::TenantScoped>::Id =
710///         __ferro_req.param_as("id").map_err(|_| ::ferro::HttpResponse::new().status(400))?;
711///     let __tenant: ::ferro::TenantContext = ::ferro::current_tenant()
712///         .ok_or_else(|| ::ferro::HttpResponse::new().status(400))?;
713///     let __resource_opt = <Customer as ::ferro::TenantScoped>::find_for_tenant(
714///         __resource_id, __tenant.id,
715///     ).await.map_err(|_| ::ferro::HttpResponse::new().status(500))?;
716///     let __resource = match __resource_opt {
717///         Some(r) => r,
718///         None => return Err(::ferro::HttpResponse::new().status(404)),
719///     };
720///     let __form_url_owned = format!("/dashboard/clienti/{}/modifica", __resource_id);
721///     let __form_url: &str = &__form_url_owned;
722///     // Inner fn borrow ends before handle_action_result borrows __ferro_req again (Pitfall 3).
723///     let __action_result: ::ferro::ActionResult =
724///         __save_inner(&mut __ferro_req, &__tenant, &__resource, __form_url).await;
725///     ::ferro::http::action::handle_action_result(
726///         __action_result, "/dashboard/clienti", "module::save", &mut __ferro_req,
727///     )
728/// }
729///
730/// // Named inner fn — tenant, resource, and __form_url are real typed parameters.
731/// async fn __save_inner(
732///     req: &mut ::ferro::Request,
733///     tenant: &::ferro::TenantContext,
734///     customer: &Customer,
735///     __form_url: &str,
736/// ) -> ::ferro::ActionResult {
737///     // user body here
738///     Ok(())
739/// }
740/// ```
741///
742/// # Security
743///
744/// Same tenant-scoping guarantee as `#[resource_get]`: lookup always passes
745/// `tenant.id`. T-212-01.
746#[proc_macro_attribute]
747pub fn resource_post(attr: TokenStream, input: TokenStream) -> TokenStream {
748    resource_post::resource_post_impl(attr, input)
749}