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}