Skip to main content

cinderblock_json_api_macros/
lib.rs

1// # JSON API Extension Proc Macro
2//
3// This proc macro is invoked by the `resource!` macro when a resource declares
4// `cinderblock_json_api` in its `extensions { ... }` block. It receives the full
5// resource DSL tokens plus the extension-specific config, and generates:
6//
7//   1. A route registration function that wires up the resource's endpoints
8//   2. An `inventory::submit!` call that auto-registers the endpoints so
9//      `cinderblock_json_api::router()` can discover them without manual wiring
10//   3. (Optional) `PartialSchema`/`ToSchema` impls and an OpenAPI spec
11//      function for the resource and its input structs
12//
13// # Route declaration
14//
15// Routes must be explicitly declared — there is no auto-generation. Each
16// route maps an HTTP method + path to an action declared on the resource.
17// The action kind (read/create/update/destroy) is inferred by looking up
18// the action name in the resource definition.
19//
20// # Config syntax
21//
22// ```text
23// cinderblock_json_api {
24//     base_path = "/api/v1/tickets";    // optional, defaults to kebab-case of resource name
25//
26//     route = { method = GET; path = "/"; action = all; };
27//     route = { method = POST; path = "/"; action = open; };
28//     route = { method = PATCH; path = "/{primary_key}/close"; action = close; };
29//     route = { method = DELETE; path = "/{primary_key}"; action = remove; };
30//
31//     openapi = true;                   // optional, defaults to true
32// };
33//
34// cinderblock_json_api {};              // no routes = silent no-op
35// ```
36
37use std::collections::HashSet;
38
39use cinderblock_extension_api::{Accept, ExtensionMacroInput, ResourceActionInputKind};
40use syn::{braced, parse::Parse, Ident, LitBool, LitStr, Token, Type};
41
42/// Supported HTTP methods for route declarations.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44enum HttpMethod {
45    Get,
46    Post,
47    Patch,
48    Put,
49    Delete,
50}
51
52impl HttpMethod {
53    /// Returns the method name as an uppercase string (e.g., `"GET"`).
54    fn as_str(&self) -> &'static str {
55        match self {
56            Self::Get => "GET",
57            Self::Post => "POST",
58            Self::Patch => "PATCH",
59            Self::Put => "PUT",
60            Self::Delete => "DELETE",
61        }
62    }
63
64    /// Returns the corresponding `axum::routing::*` function as a token stream.
65    fn axum_routing_fn(&self) -> proc_macro2::TokenStream {
66        match self {
67            Self::Get => quote::quote! { cinderblock_json_api::axum::routing::get },
68            Self::Post => quote::quote! { cinderblock_json_api::axum::routing::post },
69            Self::Patch => quote::quote! { cinderblock_json_api::axum::routing::patch },
70            Self::Put => quote::quote! { cinderblock_json_api::axum::routing::put },
71            Self::Delete => quote::quote! { cinderblock_json_api::axum::routing::delete },
72        }
73    }
74
75    /// Returns the corresponding `utoipa` `HttpMethod` variant as a token stream.
76    fn openapi_http_method(&self) -> proc_macro2::TokenStream {
77        match self {
78            Self::Get => {
79                quote::quote! { cinderblock_json_api::utoipa::openapi::path::HttpMethod::Get }
80            }
81            Self::Post => {
82                quote::quote! { cinderblock_json_api::utoipa::openapi::path::HttpMethod::Post }
83            }
84            Self::Patch => {
85                quote::quote! { cinderblock_json_api::utoipa::openapi::path::HttpMethod::Patch }
86            }
87            Self::Put => {
88                quote::quote! { cinderblock_json_api::utoipa::openapi::path::HttpMethod::Put }
89            }
90            Self::Delete => {
91                quote::quote! { cinderblock_json_api::utoipa::openapi::path::HttpMethod::Delete }
92            }
93        }
94    }
95}
96
97impl Parse for HttpMethod {
98    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
99        let ident: Ident = input.parse()?;
100        match ident.to_string().as_str() {
101            "GET" => Ok(Self::Get),
102            "POST" => Ok(Self::Post),
103            "PATCH" => Ok(Self::Patch),
104            "PUT" => Ok(Self::Put),
105            "DELETE" => Ok(Self::Delete),
106            got => Err(syn::Error::new(
107                ident.span(),
108                format!(
109                    "unsupported HTTP method `{got}`, \
110                     expected GET, POST, PATCH, PUT, or DELETE"
111                ),
112            )),
113        }
114    }
115}
116
117/// A single route declaration mapping an HTTP method + path to a resource action.
118///
119/// Parsed from `route = { method = GET; path = "/"; action = all; };`.
120struct RouteDecl {
121    method: HttpMethod,
122    /// URL path relative to `base_path` (e.g., "/" or "/{primary_key}/close").
123    path: LitStr,
124    /// Name of the action on the resource (e.g., `all`, `open`, `close`).
125    action: Ident,
126    /// Span of the `method` field for error reporting on duplicates.
127    method_span: proc_macro2::Span,
128}
129
130impl Parse for RouteDecl {
131    /// Parses the braced body of a route declaration:
132    /// `{ method = GET; path = "/"; action = all; }`
133    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
134        let mut method: Option<(HttpMethod, proc_macro2::Span)> = None;
135        let mut path: Option<LitStr> = None;
136        let mut action: Option<Ident> = None;
137
138        while !input.is_empty() {
139            let key: Ident = input.parse()?;
140            let _: Token![=] = input.parse()?;
141
142            match key.to_string().as_str() {
143                "method" => {
144                    let span = input.span();
145                    let value: HttpMethod = input.parse()?;
146                    method = Some((value, span));
147                }
148                "path" => {
149                    path = Some(input.parse()?);
150                }
151                "action" => {
152                    action = Some(input.parse()?);
153                }
154                got => {
155                    return Err(syn::Error::new(
156                        key.span(),
157                        format!("unexpected route field `{got}`, expected method, path, or action"),
158                    ));
159                }
160            }
161
162            let _: Token![;] = input.parse()?;
163        }
164
165        let (method, method_span) = method.ok_or_else(|| {
166            syn::Error::new(input.span(), "route declaration missing `method` field")
167        })?;
168        let path = path.ok_or_else(|| {
169            syn::Error::new(input.span(), "route declaration missing `path` field")
170        })?;
171        let action = action.ok_or_else(|| {
172            syn::Error::new(input.span(), "route declaration missing `action` field")
173        })?;
174
175        Ok(RouteDecl {
176            method,
177            path,
178            action,
179            method_span,
180        })
181    }
182}
183
184/// Extension-specific configuration parsed from inside the `config = { ... }`
185/// block.
186///
187/// Routes are explicitly declared — an empty config means zero endpoints
188/// are registered (silent no-op).
189struct JsonApiConfig {
190    /// Optional base path override. Defaults to the auto-derived kebab-case
191    /// path from the resource name (e.g., `Helpdesk.Support.Ticket` →
192    /// `/helpdesk/support/ticket`).
193    base_path: Option<LitStr>,
194    /// Explicit route declarations. Each maps an HTTP method + path to a
195    /// resource action.
196    routes: Vec<RouteDecl>,
197    /// When set to `false`, disables OpenAPI schema and spec generation for
198    /// this resource. Defaults to enabled.
199    openapi: Option<bool>,
200}
201
202impl JsonApiConfig {
203    /// Returns `false` only when the user explicitly set `openapi = false;`.
204    fn should_openapi(&self) -> bool {
205        self.openapi.unwrap_or(true)
206    }
207}
208
209impl Parse for JsonApiConfig {
210    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
211        let mut config = JsonApiConfig {
212            base_path: None,
213            routes: Vec::new(),
214            openapi: None,
215        };
216
217        while !input.is_empty() {
218            // Peek at the key to determine which config field to parse,
219            // without consuming it — `parse_attribute` will consume it
220            // for the simple `key = value;` cases.
221            let key: Ident = input.fork().parse()?;
222
223            match key.to_string().as_str() {
224                "base_path" => {
225                    let (_, value) =
226                        cinderblock_extension_api::parse_attribute::<LitStr>(input)?;
227                    config.base_path = Some(value);
228                }
229                "route" => {
230                    // Parses `route = { method = GET; path = "/"; action = all; };`
231                    let _: Ident = input.parse()?;
232                    let _: Token![=] = input.parse()?;
233                    let content;
234                    braced!(content in input);
235                    let route: RouteDecl = content.parse()?;
236                    config.routes.push(route);
237                    let _: Token![;] = input.parse()?;
238                }
239                "openapi" => {
240                    let (_, value) = cinderblock_extension_api::parse_attribute::<LitBool>(input)?;
241                    config.openapi = Some(value.value());
242                }
243                got => {
244                    return Err(syn::Error::new(
245                        key.span(),
246                        format!("unexpected cinderblock_json_api config key, got `{got}`"),
247                    ));
248                }
249            }
250        }
251
252        Ok(config)
253    }
254}
255
256/// Computes which attribute fields appear in a given action's input struct.
257///
258/// This replicates the field selection logic from `cinderblock-core-macros`: start
259/// with all writable attributes, then narrow by `Accept::Only` if specified.
260/// The returned list contains `(field_name, field_type)` pairs.
261fn input_fields_for_accept<'a>(
262    attributes: &'a [cinderblock_extension_api::ResourceAttributeInput],
263    accept: &Accept,
264) -> Vec<(&'a Ident, &'a syn::Type)> {
265    let writable: Vec<_> = attributes
266        .iter()
267        .filter(|attr| attr.writable.value())
268        .collect();
269
270    match accept {
271        Accept::Default => writable.iter().map(|a| (&a.name, &a.ty)).collect(),
272        Accept::Only(idents) => {
273            let names: HashSet<String> = idents.iter().map(|i| i.to_string()).collect();
274            writable
275                .iter()
276                .filter(|a| names.contains(&a.name.to_string()))
277                .map(|a| (&a.name, &a.ty))
278                .collect()
279        }
280    }
281}
282
283/// Checks whether a `syn::Type` is `Option<T>`.
284fn is_option_type(ty: &Type) -> bool {
285    if let Type::Path(type_path) = ty {
286        type_path
287            .path
288            .segments
289            .last()
290            .is_some_and(|seg| seg.ident == "Option")
291    } else {
292        false
293    }
294}
295
296/// Extracts the inner `T` from an `Option<T>` type.
297///
298/// Returns `None` if the type is not an `Option` or doesn't have exactly one
299/// generic argument.
300fn extract_option_inner_type(ty: &Type) -> Option<&Type> {
301    if let Type::Path(type_path) = ty {
302        let last_seg = type_path.path.segments.last()?;
303        if last_seg.ident != "Option" {
304            return None;
305        }
306        if let syn::PathArguments::AngleBracketed(args) = &last_seg.arguments
307            && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
308        {
309            return Some(inner);
310        }
311    }
312    None
313}
314
315#[proc_macro]
316pub fn __resource_extension(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
317    let input = syn::parse_macro_input!(item as ExtensionMacroInput<JsonApiConfig>);
318
319    let resource = &input.resource;
320    let config = &input.config;
321
322    // Derive the resource struct name from the last segment of the dotted name.
323    let ident = resource
324        .name
325        .last()
326        .expect("resource name must have at least one segment");
327
328    // # Base path derivation
329    //
330    // If the user specified `base_path = "/api/v1/tickets";`, use that.
331    // Otherwise, derive from the resource name by converting each segment
332    // to kebab-case: `Helpdesk.Support.Ticket` → `/helpdesk/support/ticket`.
333    let base_path = config
334        .base_path
335        .as_ref()
336        .map(|lit| lit.value())
337        .unwrap_or_else(|| {
338            format!(
339                "/{}",
340                resource
341                    .name
342                    .iter()
343                    .map(|s| convert_case::ccase!(kebab, s.to_string()))
344                    .collect::<Vec<_>>()
345                    .join("/")
346            )
347        });
348
349    // Generate a unique function name for the registration function to avoid
350    // collisions when multiple resources register endpoints.
351    let name_slug = resource
352        .name
353        .iter()
354        .map(|s| s.to_string().to_lowercase())
355        .collect::<Vec<_>>()
356        .join("_");
357
358    let register_fn_name = Ident::new(&format!("__register_json_api_{name_slug}"), ident.span());
359
360    // # Validation
361    //
362    // Validate all route declarations before generating code:
363    //   - Each route's action must exist in the resource's action list
364    //   - No duplicate method + path combinations
365    let mut seen_routes: HashSet<(&str, String)> = HashSet::new();
366
367    for route in &config.routes {
368        let action_name_str = route.action.to_string();
369
370        if !resource
371            .actions
372            .iter()
373            .any(|a| a.name == route.action)
374        {
375            return syn::Error::new(
376                route.action.span(),
377                format!(
378                    "route references unknown action `{action_name_str}` — \
379                     it must be declared in the resource's `actions {{ ... }}` block"
380                ),
381            )
382            .to_compile_error()
383            .into();
384        }
385
386        let route_key = (route.method.as_str(), route.path.value());
387        if !seen_routes.insert(route_key.clone()) {
388            return syn::Error::new(
389                route.method_span,
390                format!(
391                    "duplicate route: {} {} is declared more than once",
392                    route_key.0, route_key.1
393                ),
394            )
395            .to_compile_error()
396            .into();
397        }
398    }
399
400    // # Route generation
401    //
402    // For each declared route, we look up the action in the resource
403    // definition to determine its kind (read/create/update/destroy), then
404    // generate the appropriate handler with the right extractors and
405    // response types.
406    let route_registrations: Vec<_> = config
407        .routes
408        .iter()
409        .map(|route| {
410            let action_name_str = route.action.to_string();
411            let full_path = format!("{}{}", base_path, route.path.value());
412            let method_str = route.method.as_str();
413
414            let action_type_name = convert_case::ccase!(pascal, &action_name_str);
415            let action_type = Ident::new(&action_type_name, route.action.span());
416
417            // Look up the action definition — already validated above.
418            let action_def = resource
419                .actions
420                .iter()
421                .find(|a| a.name == route.action)
422                .expect("action existence validated above");
423
424            let handler_and_method = match &action_def.kind {
425                ResourceActionInputKind::Read(action_read) => {
426                    let is_paged = action_read.paged.is_some();
427                    let has_user_arguments = !action_read.arguments.is_empty();
428                    let needs_arguments_struct = has_user_arguments || is_paged;
429
430                    let handler = if is_paged {
431                        // Paged reads always have an Arguments struct
432                        // (with at least page/per_page fields).
433                        let args_type =
434                            Ident::new(&format!("{action_type_name}Arguments"), route.action.span());
435                        quote::quote! {
436                            move |
437                                cinderblock_json_api::axum::extract::Query(args): cinderblock_json_api::axum::extract::Query<#args_type>,
438                            | {
439                                let ctx = ctx.clone();
440                                async move {
441                                    cinderblock_json_api::tracing::info!(
442                                        resource = stringify!(#ident),
443                                        action = #action_name_str,
444                                        "handling paged read request"
445                                    );
446
447                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &args).await {
448                                        Ok(result) => (
449                                            cinderblock_json_api::axum::http::StatusCode::OK,
450                                            cinderblock_json_api::axum::Json(
451                                                cinderblock_json_api::PaginatedResponse {
452                                                    data: result.data,
453                                                    meta: cinderblock_json_api::PaginationMeta {
454                                                        page: result.meta.page,
455                                                        per_page: result.meta.per_page,
456                                                        total: result.meta.total,
457                                                        total_pages: result.meta.total_pages,
458                                                    },
459                                                },
460                                            ),
461                                        )
462                                            .into_response(),
463                                        Err(err) => {
464                                            cinderblock_json_api::tracing::error!(
465                                                resource = stringify!(#ident),
466                                                action = #action_name_str,
467                                                error = %err,
468                                                "paged read request failed"
469                                            );
470                                            (
471                                                cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
472                                                err.to_string(),
473                                            )
474                                                .into_response()
475                                        }
476                                    }
477                                }
478                            }
479                        }
480                    } else if needs_arguments_struct {
481                        let args_type =
482                            Ident::new(&format!("{action_type_name}Arguments"), route.action.span());
483                        quote::quote! {
484                            move |
485                                cinderblock_json_api::axum::extract::Query(args): cinderblock_json_api::axum::extract::Query<#args_type>,
486                            | {
487                                let ctx = ctx.clone();
488                                async move {
489                                    cinderblock_json_api::tracing::info!(
490                                        resource = stringify!(#ident),
491                                        action = #action_name_str,
492                                        "handling read request"
493                                    );
494
495                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &args).await {
496                                        Ok(results) => (
497                                            cinderblock_json_api::axum::http::StatusCode::OK,
498                                            cinderblock_json_api::axum::Json(
499                                                cinderblock_json_api::Response { data: results },
500                                            ),
501                                        )
502                                            .into_response(),
503                                        Err(err) => {
504                                            cinderblock_json_api::tracing::error!(
505                                                resource = stringify!(#ident),
506                                                action = #action_name_str,
507                                                error = %err,
508                                                "read request failed"
509                                            );
510                                            (
511                                                cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
512                                                err.to_string(),
513                                            )
514                                                .into_response()
515                                        }
516                                    }
517                                }
518                            }
519                        }
520                    } else {
521                        quote::quote! {
522                            move || {
523                                let ctx = ctx.clone();
524                                async move {
525                                    cinderblock_json_api::tracing::info!(
526                                        resource = stringify!(#ident),
527                                        action = #action_name_str,
528                                        "handling read request"
529                                    );
530
531                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &()).await {
532                                        Ok(results) => (
533                                            cinderblock_json_api::axum::http::StatusCode::OK,
534                                            cinderblock_json_api::axum::Json(
535                                                cinderblock_json_api::Response { data: results },
536                                            ),
537                                        )
538                                            .into_response(),
539                                        Err(err) => {
540                                            cinderblock_json_api::tracing::error!(
541                                                resource = stringify!(#ident),
542                                                action = #action_name_str,
543                                                error = %err,
544                                                "read request failed"
545                                            );
546                                            (
547                                                cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
548                                                err.to_string(),
549                                            )
550                                                .into_response()
551                                        }
552                                    }
553                                }
554                            }
555                        }
556                    };
557
558                    let routing_fn = route.method.axum_routing_fn();
559                    quote::quote! { #routing_fn(#handler) }
560                }
561                ResourceActionInputKind::Create { .. } => {
562                    let input_type =
563                        Ident::new(&format!("{action_type_name}Input"), route.action.span());
564
565                    let handler = quote::quote! {
566                        move |cinderblock_json_api::axum::Json(input): cinderblock_json_api::axum::Json<#input_type>| {
567                            let ctx = ctx.clone();
568                            async move {
569                                cinderblock_json_api::tracing::info!(
570                                    resource = stringify!(#ident),
571                                    action = #action_name_str,
572                                    "handling create request"
573                                );
574
575                                match cinderblock_core::create::<#ident, #action_type>(input, &ctx).await {
576                                    Ok(created) => (
577                                        cinderblock_json_api::axum::http::StatusCode::CREATED,
578                                        cinderblock_json_api::axum::Json(
579                                            cinderblock_json_api::Response { data: created },
580                                        ),
581                                    )
582                                        .into_response(),
583                                    Err(err) => {
584                                        cinderblock_json_api::tracing::error!(
585                                            resource = stringify!(#ident),
586                                            action = #action_name_str,
587                                            error = %err,
588                                            "create request failed"
589                                        );
590                                        (
591                                            cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
592                                            err.to_string(),
593                                        )
594                                            .into_response()
595                                    }
596                                }
597                            }
598                        }
599                    };
600
601                    let routing_fn = route.method.axum_routing_fn();
602                    quote::quote! { #routing_fn(#handler) }
603                }
604                ResourceActionInputKind::Update(_) => {
605                    let input_type =
606                        Ident::new(&format!("{action_type_name}Input"), route.action.span());
607
608                    let handler = quote::quote! {
609                        move |
610                            cinderblock_json_api::axum::extract::Path(primary_key): cinderblock_json_api::axum::extract::Path<
611                                <#ident as cinderblock_core::Resource>::PrimaryKey,
612                            >,
613                            cinderblock_json_api::axum::Json(input): cinderblock_json_api::axum::Json<#input_type>,
614                        | {
615                            let ctx = ctx.clone();
616                            async move {
617                                cinderblock_json_api::tracing::info!(
618                                    resource = stringify!(#ident),
619                                    action = #action_name_str,
620                                    %primary_key,
621                                    "handling update request"
622                                );
623
624                                match cinderblock_core::update::<#ident, #action_type>(
625                                    &primary_key,
626                                    input,
627                                    &ctx,
628                                )
629                                .await
630                                {
631                                    Ok(updated) => (
632                                        cinderblock_json_api::axum::http::StatusCode::OK,
633                                        cinderblock_json_api::axum::Json(
634                                            cinderblock_json_api::Response { data: updated },
635                                        ),
636                                    )
637                                        .into_response(),
638                                    Err(err) => {
639                                        cinderblock_json_api::tracing::error!(
640                                            resource = stringify!(#ident),
641                                            action = #action_name_str,
642                                            error = %err,
643                                            "update request failed"
644                                        );
645                                        (
646                                            cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
647                                            err.to_string(),
648                                        )
649                                            .into_response()
650                                    }
651                                }
652                            }
653                        }
654                    };
655
656                    let routing_fn = route.method.axum_routing_fn();
657                    quote::quote! { #routing_fn(#handler) }
658                }
659                ResourceActionInputKind::Destroy => {
660                    let handler = quote::quote! {
661                        move |
662                            cinderblock_json_api::axum::extract::Path(primary_key): cinderblock_json_api::axum::extract::Path<
663                                <#ident as cinderblock_core::Resource>::PrimaryKey,
664                            >,
665                        | {
666                            let ctx = ctx.clone();
667                            async move {
668                                cinderblock_json_api::tracing::info!(
669                                    resource = stringify!(#ident),
670                                    action = #action_name_str,
671                                    %primary_key,
672                                    "handling destroy request"
673                                );
674
675                                match cinderblock_core::destroy::<#ident, #action_type>(
676                                    &primary_key,
677                                    &ctx,
678                                )
679                                .await
680                                {
681                                    Ok(_) => cinderblock_json_api::axum::http::StatusCode::NO_CONTENT
682                                        .into_response(),
683                                    Err(err) => {
684                                        cinderblock_json_api::tracing::error!(
685                                            resource = stringify!(#ident),
686                                            action = #action_name_str,
687                                            error = %err,
688                                            "destroy request failed"
689                                        );
690                                        (
691                                            cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
692                                            err.to_string(),
693                                        )
694                                            .into_response()
695                                    }
696                                }
697                            }
698                        }
699                    };
700
701                    let routing_fn = route.method.axum_routing_fn();
702                    quote::quote! { #routing_fn(#handler) }
703                }
704            };
705
706            quote::quote! {
707                {
708                    let ctx = ctx.clone();
709                    cinderblock_json_api::tracing::info!(
710                        resource = stringify!(#ident),
711                        action = #action_name_str,
712                        method = #method_str,
713                        route = #full_path,
714                        "registering JSON API endpoint"
715                    );
716                    router = router.route(
717                        #full_path,
718                        #handler_and_method,
719                    );
720                }
721            }
722        })
723        .collect();
724
725    // # OpenAPI generation
726    //
727    // When `openapi` is not explicitly disabled, we generate:
728    //
729    //   1. `PartialSchema` impl for the resource struct — builds an object
730    //      schema from all attributes
731    //   2. `PartialSchema` impls for each enabled action's input struct —
732    //      replicates the field selection logic from `cinderblock-core-macros`
733    //   3. An `openapi_fn` that builds an `OpenApi` spec fragment with
734    //      component schemas and path items for all enabled endpoints
735    //
736    // User-defined types (like `TicketStatus`) must implement `PartialSchema`
737    // themselves — we delegate via `<FieldType as PartialSchema>::schema()`.
738    let openapi_impls = if config.should_openapi() {
739        let ident_str = ident.to_string();
740
741        // # Resource struct schema
742        //
743        // Build an ObjectBuilder with a `.property()` + `.required()` call
744        // for each attribute. Each field's type schema is obtained via
745        // `<Type as PartialSchema>::schema()`.
746        let resource_schema_properties: Vec<_> = resource
747            .attributes
748            .iter()
749            .map(|attr| {
750                let field_name = attr.name.to_string();
751                let field_type = &attr.ty;
752                quote::quote! {
753                    .property(
754                        #field_name,
755                        <#field_type as cinderblock_json_api::FieldSchema>::field_schema(),
756                    )
757                    .required(#field_name)
758                }
759            })
760            .collect();
761
762        let resource_schema_impl = quote::quote! {
763            impl cinderblock_json_api::utoipa::PartialSchema for #ident {
764                fn schema() -> cinderblock_json_api::utoipa::openapi::RefOr<
765                    cinderblock_json_api::utoipa::openapi::schema::Schema,
766                > {
767                    cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
768                        .schema_type(
769                            cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
770                                cinderblock_json_api::utoipa::openapi::schema::Type::Object,
771                            ),
772                        )
773                        #(#resource_schema_properties)*
774                        .into()
775                }
776            }
777
778            impl cinderblock_json_api::utoipa::ToSchema for #ident {
779                fn name() -> ::std::borrow::Cow<'static, str> {
780                    ::std::borrow::Cow::Borrowed(#ident_str)
781                }
782            }
783        };
784
785        // # Input struct schemas
786        //
787        // For each routed action that has an input struct (create and update
788        // actions), generate `PartialSchema` + `ToSchema` impls. Only actions
789        // that are actually routed get schemas.
790        let routed_action_names: HashSet<String> = config
791            .routes
792            .iter()
793            .map(|r| r.action.to_string())
794            .collect();
795
796        let input_schema_impls: Vec<_> = resource
797            .actions
798            .iter()
799            .filter_map(|action| {
800                let action_name_str = action.name.to_string();
801                if !routed_action_names.contains(&action_name_str) {
802                    return None;
803                }
804
805                let action_type_name = convert_case::ccase!(pascal, &action_name_str);
806                let input_type =
807                    Ident::new(&format!("{action_type_name}Input"), action.name.span());
808                let input_type_str = format!("{action_type_name}Input");
809
810                let accept = match &action.kind {
811                    ResourceActionInputKind::Create { accept } => accept,
812                    ResourceActionInputKind::Update(update) => &update.accept,
813                    _ => return None,
814                };
815
816                let fields = input_fields_for_accept(&resource.attributes, accept);
817
818                let properties: Vec<_> = fields
819                    .iter()
820                    .map(|(name, ty)| {
821                        let name_str = name.to_string();
822                        quote::quote! {
823                            .property(
824                                #name_str,
825                                <#ty as cinderblock_json_api::FieldSchema>::field_schema(),
826                            )
827                            .required(#name_str)
828                        }
829                    })
830                    .collect();
831
832                Some(quote::quote! {
833                    impl cinderblock_json_api::utoipa::PartialSchema for #input_type {
834                        fn schema() -> cinderblock_json_api::utoipa::openapi::RefOr<
835                            cinderblock_json_api::utoipa::openapi::schema::Schema,
836                        > {
837                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
838                                .schema_type(
839                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
840                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
841                                    ),
842                                )
843                                #(#properties)*
844                                .into()
845                        }
846                    }
847
848                    impl cinderblock_json_api::utoipa::ToSchema for #input_type {
849                        fn name() -> ::std::borrow::Cow<'static, str> {
850                            ::std::borrow::Cow::Borrowed(#input_type_str)
851                        }
852                    }
853                })
854            })
855            .collect();
856
857        // # OpenAPI spec function
858        //
859        // Builds a complete `OpenApi` fragment containing:
860        //   - Component schemas for the resource and all input structs
861        //   - Path items with operations for each declared route
862        //   - Request/response body schemas referencing the components
863        //   - Tags based on the resource struct name
864        let openapi_fn_name = Ident::new(&format!("__openapi_json_api_{name_slug}"), ident.span());
865
866        // Schema component registrations for the spec.
867        let resource_component = {
868            let ident_str_val = ident.to_string();
869            quote::quote! {
870                .schema(
871                    #ident_str_val,
872                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
873                )
874            }
875        };
876
877        let input_components: Vec<_> = resource
878            .actions
879            .iter()
880            .filter_map(|action| {
881                let action_name_str = action.name.to_string();
882                if !routed_action_names.contains(&action_name_str) {
883                    return None;
884                }
885
886                let action_type_name = convert_case::ccase!(pascal, &action_name_str);
887                let input_type =
888                    Ident::new(&format!("{action_type_name}Input"), action.name.span());
889                let input_type_str = format!("{action_type_name}Input");
890
891                // Only create/update actions have input structs.
892                match &action.kind {
893                    ResourceActionInputKind::Create { .. }
894                    | ResourceActionInputKind::Update(_) => {}
895                    _ => return None,
896                }
897
898                Some(quote::quote! {
899                    .schema(
900                        #input_type_str,
901                        <#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema(),
902                    )
903                })
904            })
905            .collect();
906
907        // # Path items for each declared route
908        //
909        // Each route declaration produces one OpenAPI path item. The action
910        // kind determines the response shape (read returns Vec, create/update
911        // returns single, destroy returns 204).
912        let ident_kebab = convert_case::ccase!(kebab, ident.to_string());
913
914        let path_items: Vec<_> = config
915            .routes
916            .iter()
917            .map(|route| {
918                let action_name_str = route.action.to_string();
919                let full_path = format!("{}{}", base_path, route.path.value());
920                let action_path_kebab = convert_case::ccase!(kebab, &action_name_str);
921                let http_method = route.method.openapi_http_method();
922                let method_lower = route.method.as_str().to_lowercase();
923                let operation_id = format!("{}-{}-{}", method_lower, ident_kebab, action_path_kebab);
924
925                let action_def = resource
926                    .actions
927                    .iter()
928                    .find(|a| a.name == route.action)
929                    .expect("action existence validated above");
930
931                // Find the primary key type for path parameter schemas.
932                let pk_type = resource
933                    .attributes
934                    .iter()
935                    .find(|a| a.primary_key.value())
936                    .map(|a| &a.ty);
937
938                // Generate a path parameter for {primary_key} if the route
939                // path contains it.
940                let pk_parameter = if route.path.value().contains("{primary_key}") {
941                    pk_type.map(|ty| {
942                        quote::quote! {
943                            .parameter(
944                                cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
945                                    .name("primary_key")
946                                    .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Path)
947                                    .required(cinderblock_json_api::utoipa::openapi::Required::True)
948                                    .schema(Some(<#ty as cinderblock_json_api::FieldSchema>::field_schema()))
949                                    .build(),
950                            )
951                        }
952                    })
953                } else {
954                    None
955                };
956
957                match &action_def.kind {
958                    ResourceActionInputKind::Read(action_read) => {
959                        let is_paged = action_read.paged.is_some();
960
961                        // Query parameters for read action arguments.
962                        let query_params: Vec<_> = action_read.arguments.iter().map(|arg| {
963                            let arg_name_str = arg.name.to_string();
964                            let arg_name_kebab = convert_case::ccase!(kebab, &arg_name_str);
965                            let is_optional = is_option_type(&arg.ty);
966
967                            let schema_type = if is_optional {
968                                extract_option_inner_type(&arg.ty).unwrap_or(&arg.ty)
969                            } else {
970                                &arg.ty
971                            };
972
973                            let required_value = if is_optional {
974                                quote::quote! { cinderblock_json_api::utoipa::openapi::Required::False }
975                            } else {
976                                quote::quote! { cinderblock_json_api::utoipa::openapi::Required::True }
977                            };
978
979                            quote::quote! {
980                                .parameter(
981                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
982                                        .name(#arg_name_kebab)
983                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
984                                        .required(#required_value)
985                                        .schema(Some(<#schema_type as cinderblock_json_api::FieldSchema>::field_schema()))
986                                        .build(),
987                                )
988                            }
989                        }).collect();
990
991                        // For paged reads, add `page` and `per_page` query params
992                        // to the OpenAPI spec.
993                        let paged_query_params = if is_paged {
994                            quote::quote! {
995                                .parameter(
996                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
997                                        .name("page")
998                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
999                                        .required(cinderblock_json_api::utoipa::openapi::Required::False)
1000                                        .schema(Some(<u32 as cinderblock_json_api::FieldSchema>::field_schema()))
1001                                        .description(Some("Page number (1-indexed, default: 1)"))
1002                                        .build(),
1003                                )
1004                                .parameter(
1005                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
1006                                        .name("per_page")
1007                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
1008                                        .required(cinderblock_json_api::utoipa::openapi::Required::False)
1009                                        .schema(Some(<u32 as cinderblock_json_api::FieldSchema>::field_schema()))
1010                                        .description(Some("Items per page"))
1011                                        .build(),
1012                                )
1013                            }
1014                        } else {
1015                            quote::quote! {}
1016                        };
1017
1018                        // Response schema differs: paged reads include meta,
1019                        // non-paged reads return { data: [...] }.
1020                        let response_schema = if is_paged {
1021                            quote::quote! {
1022                                <cinderblock_json_api::PaginatedResponse<#ident> as cinderblock_json_api::utoipa::PartialSchema>::schema()
1023                            }
1024                        } else {
1025                            quote::quote! {
1026                                cinderblock_json_api::utoipa::openapi::RefOr::<cinderblock_json_api::utoipa::openapi::schema::Schema>::from(
1027                                    cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1028                                        .schema_type(
1029                                            cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1030                                                cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1031                                            ),
1032                                        )
1033                                        .property(
1034                                            "data",
1035                                            cinderblock_json_api::utoipa::openapi::schema::ArrayBuilder::new()
1036                                                .items(<#ident as cinderblock_json_api::utoipa::PartialSchema>::schema()),
1037                                        )
1038                                        .required("data")
1039                                )
1040                            }
1041                        };
1042
1043                        let summary_prefix = if is_paged { "Paged read" } else { "Read" };
1044
1045                        quote::quote! {
1046                            .path(
1047                                #full_path,
1048                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1049                                    #http_method,
1050                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1051                                        .operation_id(Some(#operation_id))
1052                                        .tag(#ident_str)
1053                                        .summary(Some(format!("{} {} via {}", #summary_prefix, #ident_str, #action_name_str)))
1054                                        #pk_parameter
1055                                        #(#query_params)*
1056                                        #paged_query_params
1057                                        .response(
1058                                            "200",
1059                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1060                                                .description(format!("Filtered list of {}s", #ident_str))
1061                                                .content(
1062                                                    "application/json",
1063                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1064                                                        .schema(Some(#response_schema))
1065                                                        .build(),
1066                                                )
1067                                                .build(),
1068                                        )
1069                                        .build(),
1070                                ),
1071                            )
1072                        }
1073                    }
1074                    ResourceActionInputKind::Create { accept } => {
1075                        let action_type_name = convert_case::ccase!(pascal, &action_name_str);
1076                        let input_type =
1077                            Ident::new(&format!("{action_type_name}Input"), route.action.span());
1078                        let fields = input_fields_for_accept(&resource.attributes, accept);
1079                        let body_required = !fields.is_empty();
1080
1081                        quote::quote! {
1082                            .path(
1083                                #full_path,
1084                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1085                                    #http_method,
1086                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1087                                        .operation_id(Some(#operation_id))
1088                                        .tag(#ident_str)
1089                                        .summary(Some(format!("Create {} via {}", #ident_str, #action_name_str)))
1090                                        #pk_parameter
1091                                        .request_body(Some(
1092                                            cinderblock_json_api::utoipa::openapi::request_body::RequestBodyBuilder::new()
1093                                                .content(
1094                                                    "application/json",
1095                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1096                                                        .schema(Some(<#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema()))
1097                                                        .build(),
1098                                                )
1099                                                .required(Some(
1100                                                    if #body_required {
1101                                                        cinderblock_json_api::utoipa::openapi::Required::True
1102                                                    } else {
1103                                                        cinderblock_json_api::utoipa::openapi::Required::False
1104                                                    },
1105                                                ))
1106                                                .build(),
1107                                        ))
1108                                        .response(
1109                                            "201",
1110                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1111                                                .description(format!("{} created", #ident_str))
1112                                                .content(
1113                                                    "application/json",
1114                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1115                                                        .schema(Some(
1116                                                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1117                                                                .schema_type(
1118                                                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1119                                                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1120                                                                    ),
1121                                                                )
1122                                                                .property(
1123                                                                    "data",
1124                                                                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
1125                                                                )
1126                                                                .required("data"),
1127                                                        ))
1128                                                        .build(),
1129                                                )
1130                                                .build(),
1131                                        )
1132                                        .build(),
1133                                ),
1134                            )
1135                        }
1136                    }
1137                    ResourceActionInputKind::Update(update) => {
1138                        let action_type_name = convert_case::ccase!(pascal, &action_name_str);
1139                        let input_type =
1140                            Ident::new(&format!("{action_type_name}Input"), route.action.span());
1141                        let fields = input_fields_for_accept(&resource.attributes, &update.accept);
1142                        let body_required = !fields.is_empty();
1143
1144                        quote::quote! {
1145                            .path(
1146                                #full_path,
1147                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1148                                    #http_method,
1149                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1150                                        .operation_id(Some(#operation_id))
1151                                        .tag(#ident_str)
1152                                        .summary(Some(format!("Update {} via {}", #ident_str, #action_name_str)))
1153                                        #pk_parameter
1154                                        .request_body(Some(
1155                                            cinderblock_json_api::utoipa::openapi::request_body::RequestBodyBuilder::new()
1156                                                .content(
1157                                                    "application/json",
1158                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1159                                                        .schema(Some(<#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema()))
1160                                                        .build(),
1161                                                )
1162                                                .required(Some(
1163                                                    if #body_required {
1164                                                        cinderblock_json_api::utoipa::openapi::Required::True
1165                                                    } else {
1166                                                        cinderblock_json_api::utoipa::openapi::Required::False
1167                                                    },
1168                                                ))
1169                                                .build(),
1170                                        ))
1171                                        .response(
1172                                            "200",
1173                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1174                                                .description(format!("{} updated", #ident_str))
1175                                                .content(
1176                                                    "application/json",
1177                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1178                                                        .schema(Some(
1179                                                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1180                                                                .schema_type(
1181                                                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1182                                                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1183                                                                    ),
1184                                                                )
1185                                                                .property(
1186                                                                    "data",
1187                                                                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
1188                                                                )
1189                                                                .required("data"),
1190                                                        ))
1191                                                        .build(),
1192                                                )
1193                                                .build(),
1194                                        )
1195                                        .build(),
1196                                ),
1197                            )
1198                        }
1199                    }
1200                    ResourceActionInputKind::Destroy => {
1201                        quote::quote! {
1202                            .path(
1203                                #full_path,
1204                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1205                                    #http_method,
1206                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1207                                        .operation_id(Some(#operation_id))
1208                                        .tag(#ident_str)
1209                                        .summary(Some(format!("Destroy {} via {}", #ident_str, #action_name_str)))
1210                                        #pk_parameter
1211                                        .response(
1212                                            "204",
1213                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1214                                                .description(format!("{} destroyed", #ident_str))
1215                                                .build(),
1216                                        )
1217                                        .build(),
1218                                ),
1219                            )
1220                        }
1221                    }
1222                }
1223            })
1224            .collect();
1225
1226        Some(quote::quote! {
1227            #resource_schema_impl
1228            #(#input_schema_impls)*
1229
1230            fn #openapi_fn_name() -> cinderblock_json_api::utoipa::openapi::OpenApi {
1231                cinderblock_json_api::utoipa::openapi::OpenApiBuilder::new()
1232                    .components(Some(
1233                        cinderblock_json_api::utoipa::openapi::ComponentsBuilder::new()
1234                            #resource_component
1235                            #(#input_components)*
1236                            .build(),
1237                    ))
1238                    .paths(
1239                        cinderblock_json_api::utoipa::openapi::PathsBuilder::new()
1240                            #(#path_items)*
1241                            .build(),
1242                    )
1243                    .build()
1244            }
1245        })
1246    } else {
1247        None
1248    };
1249
1250    // # Inventory submission
1251    //
1252    // The `openapi` field is populated when OpenAPI generation is enabled,
1253    // or set to `None` when the user disabled it with `openapi = false;`.
1254    let openapi_fn_name = Ident::new(&format!("__openapi_json_api_{name_slug}"), ident.span());
1255
1256    let openapi_field = if config.should_openapi() {
1257        quote::quote! { openapi: Some(#openapi_fn_name) }
1258    } else {
1259        quote::quote! { openapi: None }
1260    };
1261
1262    quote::quote! {
1263        fn #register_fn_name(
1264            mut router: cinderblock_json_api::axum::Router,
1265            ctx: ::std::sync::Arc<cinderblock_core::Context>,
1266        ) -> cinderblock_json_api::axum::Router {
1267            use cinderblock_json_api::axum::response::IntoResponse;
1268
1269            #(#route_registrations)*
1270
1271            router
1272        }
1273
1274        #openapi_impls
1275
1276        cinderblock_json_api::inventory::submit! {
1277            cinderblock_json_api::ResourceEndpoint {
1278                register: #register_fn_name,
1279                #openapi_field,
1280            }
1281        }
1282    }
1283    .into()
1284}