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::{Ident, LitBool, LitStr, Token, Type, braced, parse::Parse};
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) = cinderblock_extension_api::parse_attribute::<LitStr>(input)?;
226                    config.base_path = Some(value);
227                }
228                "route" => {
229                    // Parses `route = { method = GET; path = "/"; action = all; };`
230                    let _: Ident = input.parse()?;
231                    let _: Token![=] = input.parse()?;
232                    let content;
233                    braced!(content in input);
234                    let route: RouteDecl = content.parse()?;
235                    config.routes.push(route);
236                    let _: Token![;] = input.parse()?;
237                }
238                "openapi" => {
239                    let (_, value) = cinderblock_extension_api::parse_attribute::<LitBool>(input)?;
240                    config.openapi = Some(value.value());
241                }
242                got => {
243                    return Err(syn::Error::new(
244                        key.span(),
245                        format!("unexpected cinderblock_json_api config key, got `{got}`"),
246                    ));
247                }
248            }
249        }
250
251        Ok(config)
252    }
253}
254
255/// Computes which attribute fields appear in a given action's input struct.
256///
257/// This replicates the field selection logic from `cinderblock-core-macros`: start
258/// with all writable attributes, then narrow by `Accept::Only` if specified.
259/// The returned list contains `(field_name, field_type)` pairs.
260fn input_fields_for_accept<'a>(
261    attributes: &'a [cinderblock_extension_api::ResourceAttributeInput],
262    accept: &Accept,
263) -> Vec<(&'a Ident, &'a syn::Type)> {
264    let writable: Vec<_> = attributes
265        .iter()
266        .filter(|attr| attr.writable.value())
267        .collect();
268
269    match accept {
270        Accept::Default => writable.iter().map(|a| (&a.name, &a.ty)).collect(),
271        Accept::Only(idents) => {
272            let names: HashSet<String> = idents.iter().map(|i| i.to_string()).collect();
273            writable
274                .iter()
275                .filter(|a| names.contains(&a.name.to_string()))
276                .map(|a| (&a.name, &a.ty))
277                .collect()
278        }
279    }
280}
281
282/// Checks whether a `syn::Type` is `Option<T>`.
283fn is_option_type(ty: &Type) -> bool {
284    if let Type::Path(type_path) = ty {
285        type_path
286            .path
287            .segments
288            .last()
289            .is_some_and(|seg| seg.ident == "Option")
290    } else {
291        false
292    }
293}
294
295/// Extracts the inner `T` from an `Option<T>` type.
296///
297/// Returns `None` if the type is not an `Option` or doesn't have exactly one
298/// generic argument.
299fn extract_option_inner_type(ty: &Type) -> Option<&Type> {
300    if let Type::Path(type_path) = ty {
301        let last_seg = type_path.path.segments.last()?;
302        if last_seg.ident != "Option" {
303            return None;
304        }
305        if let syn::PathArguments::AngleBracketed(args) = &last_seg.arguments
306            && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
307        {
308            return Some(inner);
309        }
310    }
311    None
312}
313
314#[proc_macro]
315pub fn __resource_extension(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
316    let input = syn::parse_macro_input!(item as ExtensionMacroInput<JsonApiConfig>);
317
318    let resource = &input.resource;
319    let config = &input.config;
320
321    // Derive the resource struct name from the last segment of the dotted name.
322    let ident = resource
323        .name
324        .last()
325        .expect("resource name must have at least one segment");
326
327    // # Base path derivation
328    //
329    // If the user specified `base_path = "/api/v1/tickets";`, use that.
330    // Otherwise, derive from the resource name by converting each segment
331    // to kebab-case: `Helpdesk.Support.Ticket` → `/helpdesk/support/ticket`.
332    let base_path = config
333        .base_path
334        .as_ref()
335        .map(|lit| lit.value())
336        .unwrap_or_else(|| {
337            format!(
338                "/{}",
339                resource
340                    .name
341                    .iter()
342                    .map(|s| convert_case::ccase!(kebab, s.to_string()))
343                    .collect::<Vec<_>>()
344                    .join("/")
345            )
346        });
347
348    // Generate a unique function name for the registration function to avoid
349    // collisions when multiple resources register endpoints.
350    let name_slug = resource
351        .name
352        .iter()
353        .map(|s| s.to_string().to_lowercase())
354        .collect::<Vec<_>>()
355        .join("_");
356
357    let register_fn_name = Ident::new(&format!("__register_json_api_{name_slug}"), ident.span());
358
359    // # Validation
360    //
361    // Validate all route declarations before generating code:
362    //   - Each route's action must exist in the resource's action list
363    //   - No duplicate method + path combinations
364    let mut seen_routes: HashSet<(&str, String)> = HashSet::new();
365
366    for route in &config.routes {
367        let action_name_str = route.action.to_string();
368
369        if !resource.actions.iter().any(|a| a.name == route.action) {
370            return syn::Error::new(
371                route.action.span(),
372                format!(
373                    "route references unknown action `{action_name_str}` — \
374                     it must be declared in the resource's `actions {{ ... }}` block"
375                ),
376            )
377            .to_compile_error()
378            .into();
379        }
380
381        let route_key = (route.method.as_str(), route.path.value());
382        if !seen_routes.insert(route_key.clone()) {
383            return syn::Error::new(
384                route.method_span,
385                format!(
386                    "duplicate route: {} {} is declared more than once",
387                    route_key.0, route_key.1
388                ),
389            )
390            .to_compile_error()
391            .into();
392        }
393    }
394
395    // # Route generation
396    //
397    // For each declared route, we look up the action in the resource
398    // definition to determine its kind (read/create/update/destroy), then
399    // generate the appropriate handler with the right extractors and
400    // response types.
401    let route_registrations: Vec<_> = config
402        .routes
403        .iter()
404        .map(|route| {
405            let action_name_str = route.action.to_string();
406            let full_path = format!("{}{}", base_path, route.path.value());
407            let method_str = route.method.as_str();
408
409            let action_type_name = convert_case::ccase!(pascal, &action_name_str);
410            let action_type = Ident::new(&action_type_name, route.action.span());
411
412            // Look up the action definition — already validated above.
413            let action_def = resource
414                .actions
415                .iter()
416                .find(|a| a.name == route.action)
417                .expect("action existence validated above");
418
419            let handler_and_method = match &action_def.kind {
420                ResourceActionInputKind::Read(action_read) => {
421                    let is_get = action_read.get;
422                    let is_paged = action_read.paged.is_some();
423                    let has_user_arguments = !action_read.arguments.is_empty();
424                    let needs_arguments_struct = has_user_arguments || is_paged;
425
426                    let handler = if is_get {
427                        quote::quote! {
428                            move |
429                                cinderblock_json_api::axum::extract::Path(primary_key): cinderblock_json_api::axum::extract::Path<
430                                    <#ident as cinderblock_core::Resource>::PrimaryKey,
431                                >,
432                            | {
433                                let ctx = ctx.clone();
434                                async move {
435                                    cinderblock_json_api::tracing::info!(
436                                        resource = stringify!(#ident),
437                                        action = #action_name_str,
438                                        %primary_key,
439                                        "handling get request"
440                                    );
441
442                                    match cinderblock_core::read_one::<#ident, #action_type>(&ctx, &primary_key).await {
443                                        Ok(result) => (
444                                            cinderblock_json_api::axum::http::StatusCode::OK,
445                                            cinderblock_json_api::axum::Json(
446                                                cinderblock_json_api::Response { data: result },
447                                            ),
448                                        )
449                                            .into_response(),
450                                        Err(err) => {
451                                            cinderblock_json_api::tracing::error!(
452                                                resource = stringify!(#ident),
453                                                action = #action_name_str,
454                                                error = %err,
455                                                "get request failed"
456                                            );
457                                            let status = match err.data() {
458                                                cinderblock_core::ReadError::NotFound { .. } =>
459                                                    cinderblock_json_api::axum::http::StatusCode::NOT_FOUND,
460                                                cinderblock_core::ReadError::DataLayer(_) =>
461                                                    cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
462                                            };
463                                            (status, err.to_string()).into_response()
464                                        }
465                                    }
466                                }
467                            }
468                        }
469                    } else if is_paged {
470                        // Paged reads always have an Arguments struct
471                        // (with at least page/per_page fields).
472                        let args_type =
473                            Ident::new(&format!("{action_type_name}Arguments"), route.action.span());
474                        quote::quote! {
475                            move |
476                                cinderblock_json_api::axum::extract::Query(args): cinderblock_json_api::axum::extract::Query<#args_type>,
477                            | {
478                                let ctx = ctx.clone();
479                                async move {
480                                    cinderblock_json_api::tracing::info!(
481                                        resource = stringify!(#ident),
482                                        action = #action_name_str,
483                                        "handling paged read request"
484                                    );
485
486                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &args).await {
487                                        Ok(result) => (
488                                            cinderblock_json_api::axum::http::StatusCode::OK,
489                                            cinderblock_json_api::axum::Json(
490                                                cinderblock_json_api::PaginatedResponse {
491                                                    data: result.data,
492                                                    meta: cinderblock_json_api::PaginationMeta {
493                                                        page: result.meta.page,
494                                                        per_page: result.meta.per_page,
495                                                        total: result.meta.total,
496                                                        total_pages: result.meta.total_pages,
497                                                    },
498                                                },
499                                            ),
500                                        )
501                                            .into_response(),
502                                        Err(err) => {
503                                            cinderblock_json_api::tracing::error!(
504                                                resource = stringify!(#ident),
505                                                action = #action_name_str,
506                                                error = %err,
507                                                "paged read request failed"
508                                            );
509                                            let status = match err.data() {
510                                                cinderblock_core::ListError::DataLayer(_) =>
511                                                    cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
512                                            };
513                                            (status, err.to_string()).into_response()
514                                        }
515                                    }
516                                }
517                            }
518                        }
519                    } else if needs_arguments_struct {
520                        let args_type =
521                            Ident::new(&format!("{action_type_name}Arguments"), route.action.span());
522                        quote::quote! {
523                            move |
524                                cinderblock_json_api::axum::extract::Query(args): cinderblock_json_api::axum::extract::Query<#args_type>,
525                            | {
526                                let ctx = ctx.clone();
527                                async move {
528                                    cinderblock_json_api::tracing::info!(
529                                        resource = stringify!(#ident),
530                                        action = #action_name_str,
531                                        "handling read request"
532                                    );
533
534                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &args).await {
535                                        Ok(results) => (
536                                            cinderblock_json_api::axum::http::StatusCode::OK,
537                                            cinderblock_json_api::axum::Json(
538                                                cinderblock_json_api::Response { data: results },
539                                            ),
540                                        )
541                                            .into_response(),
542                                        Err(err) => {
543                                            cinderblock_json_api::tracing::error!(
544                                                resource = stringify!(#ident),
545                                                action = #action_name_str,
546                                                error = %err,
547                                                "read request failed"
548                                            );
549                                            let status = match err.data() {
550                                                cinderblock_core::ListError::DataLayer(_) =>
551                                                    cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
552                                            };
553                                            (status, err.to_string()).into_response()
554                                        }
555                                    }
556                                }
557                            }
558                        }
559                    } else {
560                        quote::quote! {
561                            move || {
562                                let ctx = ctx.clone();
563                                async move {
564                                    cinderblock_json_api::tracing::info!(
565                                        resource = stringify!(#ident),
566                                        action = #action_name_str,
567                                        "handling read request"
568                                    );
569
570                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &()).await {
571                                        Ok(results) => (
572                                            cinderblock_json_api::axum::http::StatusCode::OK,
573                                            cinderblock_json_api::axum::Json(
574                                                cinderblock_json_api::Response { data: results },
575                                            ),
576                                        )
577                                            .into_response(),
578                                        Err(err) => {
579                                            cinderblock_json_api::tracing::error!(
580                                                resource = stringify!(#ident),
581                                                action = #action_name_str,
582                                                error = %err,
583                                                "read request failed"
584                                            );
585                                            let status = match err.data() {
586                                                cinderblock_core::ListError::DataLayer(_) =>
587                                                    cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
588                                            };
589                                            (status, err.to_string()).into_response()
590                                        }
591                                    }
592                                }
593                            }
594                        }
595                    };
596
597                    let routing_fn = route.method.axum_routing_fn();
598                    quote::quote! { #routing_fn(#handler) }
599                }
600                ResourceActionInputKind::Create { .. } => {
601                    let input_type =
602                        Ident::new(&format!("{action_type_name}Input"), route.action.span());
603
604                    let handler = quote::quote! {
605                        move |cinderblock_json_api::axum::Json(input): cinderblock_json_api::axum::Json<#input_type>| {
606                            let ctx = ctx.clone();
607                            async move {
608                                cinderblock_json_api::tracing::info!(
609                                    resource = stringify!(#ident),
610                                    action = #action_name_str,
611                                    "handling create request"
612                                );
613
614                                match cinderblock_core::create::<#ident, #action_type>(input, &ctx).await {
615                                    Ok(created) => (
616                                        cinderblock_json_api::axum::http::StatusCode::CREATED,
617                                        cinderblock_json_api::axum::Json(
618                                            cinderblock_json_api::Response { data: created },
619                                        ),
620                                    )
621                                        .into_response(),
622                                    Err(err) => {
623                                        cinderblock_json_api::tracing::error!(
624                                            resource = stringify!(#ident),
625                                            action = #action_name_str,
626                                            error = %err,
627                                            "create request failed"
628                                        );
629                                        let status = match err.data() {
630                                            cinderblock_core::CreateError::DataLayer(_) =>
631                                                cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
632                                        };
633                                        (status, err.to_string()).into_response()
634                                    }
635                                }
636                            }
637                        }
638                    };
639
640                    let routing_fn = route.method.axum_routing_fn();
641                    quote::quote! { #routing_fn(#handler) }
642                }
643                ResourceActionInputKind::Update(_) => {
644                    let input_type =
645                        Ident::new(&format!("{action_type_name}Input"), route.action.span());
646
647                    let handler = quote::quote! {
648                        move |
649                            cinderblock_json_api::axum::extract::Path(primary_key): cinderblock_json_api::axum::extract::Path<
650                                <#ident as cinderblock_core::Resource>::PrimaryKey,
651                            >,
652                            cinderblock_json_api::axum::Json(input): cinderblock_json_api::axum::Json<#input_type>,
653                        | {
654                            let ctx = ctx.clone();
655                            async move {
656                                cinderblock_json_api::tracing::info!(
657                                    resource = stringify!(#ident),
658                                    action = #action_name_str,
659                                    %primary_key,
660                                    "handling update request"
661                                );
662
663                                match cinderblock_core::update::<#ident, #action_type>(
664                                    &primary_key,
665                                    input,
666                                    &ctx,
667                                )
668                                .await
669                                {
670                                    Ok(updated) => (
671                                        cinderblock_json_api::axum::http::StatusCode::OK,
672                                        cinderblock_json_api::axum::Json(
673                                            cinderblock_json_api::Response { data: updated },
674                                        ),
675                                    )
676                                        .into_response(),
677                                    Err(err) => {
678                                        cinderblock_json_api::tracing::error!(
679                                            resource = stringify!(#ident),
680                                            action = #action_name_str,
681                                            error = %err,
682                                            "update request failed"
683                                        );
684                                        let status = match err.data() {
685                                            cinderblock_core::UpdateError::NotFound { .. } =>
686                                                cinderblock_json_api::axum::http::StatusCode::NOT_FOUND,
687                                            cinderblock_core::UpdateError::DataLayer(_) =>
688                                                cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
689                                        };
690                                        (status, err.to_string()).into_response()
691                                    }
692                                }
693                            }
694                        }
695                    };
696
697                    let routing_fn = route.method.axum_routing_fn();
698                    quote::quote! { #routing_fn(#handler) }
699                }
700                ResourceActionInputKind::Destroy => {
701                    let handler = quote::quote! {
702                        move |
703                            cinderblock_json_api::axum::extract::Path(primary_key): cinderblock_json_api::axum::extract::Path<
704                                <#ident as cinderblock_core::Resource>::PrimaryKey,
705                            >,
706                        | {
707                            let ctx = ctx.clone();
708                            async move {
709                                cinderblock_json_api::tracing::info!(
710                                    resource = stringify!(#ident),
711                                    action = #action_name_str,
712                                    %primary_key,
713                                    "handling destroy request"
714                                );
715
716                                match cinderblock_core::destroy::<#ident, #action_type>(
717                                    &primary_key,
718                                    &ctx,
719                                )
720                                .await
721                                {
722                                    Ok(_) => cinderblock_json_api::axum::http::StatusCode::NO_CONTENT
723                                        .into_response(),
724                                    Err(err) => {
725                                        cinderblock_json_api::tracing::error!(
726                                            resource = stringify!(#ident),
727                                            action = #action_name_str,
728                                            error = %err,
729                                            "destroy request failed"
730                                        );
731                                        let status = match err.data() {
732                                            cinderblock_core::DestroyError::NotFound { .. } =>
733                                                cinderblock_json_api::axum::http::StatusCode::NOT_FOUND,
734                                            cinderblock_core::DestroyError::DataLayer(_) =>
735                                                cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
736                                        };
737                                        (status, err.to_string()).into_response()
738                                    }
739                                }
740                            }
741                        }
742                    };
743
744                    let routing_fn = route.method.axum_routing_fn();
745                    quote::quote! { #routing_fn(#handler) }
746                }
747            };
748
749            quote::quote! {
750                {
751                    let ctx = ctx.clone();
752                    cinderblock_json_api::tracing::info!(
753                        resource = stringify!(#ident),
754                        action = #action_name_str,
755                        method = #method_str,
756                        route = #full_path,
757                        "registering JSON API endpoint"
758                    );
759                    router = router.route(
760                        #full_path,
761                        #handler_and_method,
762                    );
763                }
764            }
765        })
766        .collect();
767
768    // # OpenAPI generation
769    //
770    // When `openapi` is not explicitly disabled, we generate:
771    //
772    //   1. `PartialSchema` impl for the resource struct — builds an object
773    //      schema from all attributes
774    //   2. `PartialSchema` impls for each enabled action's input struct —
775    //      replicates the field selection logic from `cinderblock-core-macros`
776    //   3. An `openapi_fn` that builds an `OpenApi` spec fragment with
777    //      component schemas and path items for all enabled endpoints
778    //
779    // User-defined types (like `TicketStatus`) must implement `PartialSchema`
780    // themselves — we delegate via `<FieldType as PartialSchema>::schema()`.
781    let openapi_impls = if config.should_openapi() {
782        let ident_str = ident.to_string();
783
784        // # Resource struct schema
785        //
786        // Build an ObjectBuilder with a `.property()` + `.required()` call
787        // for each attribute. Each field's type schema is obtained via
788        // `<Type as PartialSchema>::schema()`.
789        let resource_schema_properties: Vec<_> = resource
790            .attributes
791            .iter()
792            .map(|attr| {
793                let field_name = attr.name.to_string();
794                let field_type = &attr.ty;
795                let is_optional = is_option_type(field_type);
796
797                let required_clause = if is_optional {
798                    quote::quote! {}
799                } else {
800                    quote::quote! { .required(#field_name) }
801                };
802
803                quote::quote! {
804                    .property(
805                        #field_name,
806                        <#field_type as cinderblock_json_api::FieldSchema>::field_schema(),
807                    )
808                    #required_clause
809                }
810            })
811            .collect();
812
813        let resource_schema_impl = quote::quote! {
814            impl cinderblock_json_api::utoipa::PartialSchema for #ident {
815                fn schema() -> cinderblock_json_api::utoipa::openapi::RefOr<
816                    cinderblock_json_api::utoipa::openapi::schema::Schema,
817                > {
818                    cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
819                        .schema_type(
820                            cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
821                                cinderblock_json_api::utoipa::openapi::schema::Type::Object,
822                            ),
823                        )
824                        #(#resource_schema_properties)*
825                        .into()
826                }
827            }
828
829            impl cinderblock_json_api::utoipa::ToSchema for #ident {
830                fn name() -> ::std::borrow::Cow<'static, str> {
831                    ::std::borrow::Cow::Borrowed(#ident_str)
832                }
833            }
834        };
835
836        // # Input struct schemas
837        //
838        // For each routed action that has an input struct (create and update
839        // actions), generate `PartialSchema` + `ToSchema` impls. Only actions
840        // that are actually routed get schemas.
841        let routed_action_names: HashSet<String> =
842            config.routes.iter().map(|r| r.action.to_string()).collect();
843
844        let input_schema_impls: Vec<_> = resource
845            .actions
846            .iter()
847            .filter_map(|action| {
848                let action_name_str = action.name.to_string();
849                if !routed_action_names.contains(&action_name_str) {
850                    return None;
851                }
852
853                let action_type_name = convert_case::ccase!(pascal, &action_name_str);
854                let input_type =
855                    Ident::new(&format!("{action_type_name}Input"), action.name.span());
856                let input_type_str = format!("{action_type_name}Input");
857
858                let accept = match &action.kind {
859                    ResourceActionInputKind::Create { accept } => accept,
860                    ResourceActionInputKind::Update(update) => &update.accept,
861                    _ => return None,
862                };
863
864                let fields = input_fields_for_accept(&resource.attributes, accept);
865
866                let properties: Vec<_> = fields
867                    .iter()
868                    .map(|(name, ty)| {
869                        let name_str = name.to_string();
870                        let is_optional = is_option_type(ty);
871
872                        let required_clause = if is_optional {
873                            quote::quote! {}
874                        } else {
875                            quote::quote! { .required(#name_str) }
876                        };
877
878                        quote::quote! {
879                            .property(
880                                #name_str,
881                                <#ty as cinderblock_json_api::FieldSchema>::field_schema(),
882                            )
883                            #required_clause
884                        }
885                    })
886                    .collect();
887
888                Some(quote::quote! {
889                    impl cinderblock_json_api::utoipa::PartialSchema for #input_type {
890                        fn schema() -> cinderblock_json_api::utoipa::openapi::RefOr<
891                            cinderblock_json_api::utoipa::openapi::schema::Schema,
892                        > {
893                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
894                                .schema_type(
895                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
896                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
897                                    ),
898                                )
899                                #(#properties)*
900                                .into()
901                        }
902                    }
903
904                    impl cinderblock_json_api::utoipa::ToSchema for #input_type {
905                        fn name() -> ::std::borrow::Cow<'static, str> {
906                            ::std::borrow::Cow::Borrowed(#input_type_str)
907                        }
908                    }
909                })
910            })
911            .collect();
912
913        // # OpenAPI spec function
914        //
915        // Builds a complete `OpenApi` fragment containing:
916        //   - Component schemas for the resource and all input structs
917        //   - Path items with operations for each declared route
918        //   - Request/response body schemas referencing the components
919        //   - Tags based on the resource struct name
920        let openapi_fn_name = Ident::new(&format!("__openapi_json_api_{name_slug}"), ident.span());
921
922        // Schema component registrations for the spec.
923        let resource_component = {
924            let ident_str_val = ident.to_string();
925            quote::quote! {
926                .schema(
927                    #ident_str_val,
928                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
929                )
930            }
931        };
932
933        let input_components: Vec<_> = resource
934            .actions
935            .iter()
936            .filter_map(|action| {
937                let action_name_str = action.name.to_string();
938                if !routed_action_names.contains(&action_name_str) {
939                    return None;
940                }
941
942                let action_type_name = convert_case::ccase!(pascal, &action_name_str);
943                let input_type =
944                    Ident::new(&format!("{action_type_name}Input"), action.name.span());
945                let input_type_str = format!("{action_type_name}Input");
946
947                // Only create/update actions have input structs.
948                match &action.kind {
949                    ResourceActionInputKind::Create { .. } | ResourceActionInputKind::Update(_) => {
950                    }
951                    _ => return None,
952                }
953
954                Some(quote::quote! {
955                    .schema(
956                        #input_type_str,
957                        <#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema(),
958                    )
959                })
960            })
961            .collect();
962
963        // # Path items for each declared route
964        //
965        // Each route declaration produces one OpenAPI path item. The action
966        // kind determines the response shape (read returns Vec, create/update
967        // returns single, destroy returns 204).
968        let ident_kebab = convert_case::ccase!(kebab, ident.to_string());
969
970        let path_items: Vec<_> = config
971            .routes
972            .iter()
973            .map(|route| {
974                let action_name_str = route.action.to_string();
975                let full_path = format!("{}{}", base_path, route.path.value());
976                let action_path_kebab = convert_case::ccase!(kebab, &action_name_str);
977                let http_method = route.method.openapi_http_method();
978                let method_lower = route.method.as_str().to_lowercase();
979                let operation_id = format!("{}-{}-{}", method_lower, ident_kebab, action_path_kebab);
980
981                let action_def = resource
982                    .actions
983                    .iter()
984                    .find(|a| a.name == route.action)
985                    .expect("action existence validated above");
986
987                // Find the primary key type for path parameter schemas.
988                let pk_type = resource
989                    .attributes
990                    .iter()
991                    .find(|a| a.primary_key.value())
992                    .map(|a| &a.ty);
993
994                // Generate a path parameter for {primary_key} if the route
995                // path contains it.
996                let pk_parameter = if route.path.value().contains("{primary_key}") {
997                    pk_type.map(|ty| {
998                        quote::quote! {
999                            .parameter(
1000                                cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
1001                                    .name("primary_key")
1002                                    .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Path)
1003                                    .required(cinderblock_json_api::utoipa::openapi::Required::True)
1004                                    .schema(Some(<#ty as cinderblock_json_api::FieldSchema>::field_schema()))
1005                                    .build(),
1006                            )
1007                        }
1008                    })
1009                } else {
1010                    None
1011                };
1012
1013                match &action_def.kind {
1014                    ResourceActionInputKind::Read(action_read) => {
1015                        let is_get = action_read.get;
1016                        let is_paged = action_read.paged.is_some();
1017
1018                        if is_get {
1019                            quote::quote! {
1020                                .path(
1021                                    #full_path,
1022                                    cinderblock_json_api::utoipa::openapi::PathItem::new(
1023                                        #http_method,
1024                                        cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1025                                            .operation_id(Some(#operation_id))
1026                                            .tag(#ident_str)
1027                                            .summary(Some(format!("Get {} via {}", #ident_str, #action_name_str)))
1028                                            #pk_parameter
1029                                            .response(
1030                                                "200",
1031                                                cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1032                                                    .description(format!("Single {}", #ident_str))
1033                                                    .content(
1034                                                        "application/json",
1035                                                        cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1036                                                            .schema(Some(
1037                                                                cinderblock_json_api::utoipa::openapi::RefOr::<cinderblock_json_api::utoipa::openapi::schema::Schema>::from(
1038                                                                    cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1039                                                                        .schema_type(
1040                                                                            cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1041                                                                                cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1042                                                                            ),
1043                                                                        )
1044                                                                        .property(
1045                                                                            "data",
1046                                                                            <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
1047                                                                        )
1048                                                                        .required("data")
1049                                                                )
1050                                                            ))
1051                                                            .build(),
1052                                                    )
1053                                                    .build(),
1054                                            )
1055                                            .response(
1056                                                "404",
1057                                                cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1058                                                    .description("Not found")
1059                                                    .build(),
1060                                            )
1061                                        .build(),
1062                                ),
1063                            )
1064                            }
1065                        } else {
1066
1067                        // Query parameters for read action arguments.
1068                        //
1069                        // Parameter names use the original Rust field name
1070                        // (snake_case) so that the OpenAPI spec matches what
1071                        // serde actually deserializes from the query string.
1072                        let query_params: Vec<_> = action_read.arguments.iter().map(|arg| {
1073                            let arg_param_name = arg.name.to_string();
1074                            let is_optional = is_option_type(&arg.ty);
1075
1076                            let schema_type = if is_optional {
1077                                extract_option_inner_type(&arg.ty).unwrap_or(&arg.ty)
1078                            } else {
1079                                &arg.ty
1080                            };
1081
1082                            let required_value = if is_optional {
1083                                quote::quote! { cinderblock_json_api::utoipa::openapi::Required::False }
1084                            } else {
1085                                quote::quote! { cinderblock_json_api::utoipa::openapi::Required::True }
1086                            };
1087
1088                            quote::quote! {
1089                                .parameter(
1090                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
1091                                        .name(#arg_param_name)
1092                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
1093                                        .required(#required_value)
1094                                        .schema(Some(<#schema_type as cinderblock_json_api::FieldSchema>::field_schema()))
1095                                        .build(),
1096                                )
1097                            }
1098                        }).collect();
1099
1100                        // For paged reads, add `page` and `per_page` query params
1101                        // to the OpenAPI spec.
1102                        let paged_query_params = if is_paged {
1103                            quote::quote! {
1104                                .parameter(
1105                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
1106                                        .name("page")
1107                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
1108                                        .required(cinderblock_json_api::utoipa::openapi::Required::False)
1109                                        .schema(Some(<u32 as cinderblock_json_api::FieldSchema>::field_schema()))
1110                                        .description(Some("Page number (1-indexed, default: 1)"))
1111                                        .build(),
1112                                )
1113                                .parameter(
1114                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
1115                                        .name("per_page")
1116                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
1117                                        .required(cinderblock_json_api::utoipa::openapi::Required::False)
1118                                        .schema(Some(<u32 as cinderblock_json_api::FieldSchema>::field_schema()))
1119                                        .description(Some("Items per page"))
1120                                        .build(),
1121                                )
1122                            }
1123                        } else {
1124                            quote::quote! {}
1125                        };
1126
1127                        // Response schema differs: paged reads include meta,
1128                        // non-paged reads return { data: [...] }.
1129                        let response_schema = if is_paged {
1130                            quote::quote! {
1131                                <cinderblock_json_api::PaginatedResponse<#ident> as cinderblock_json_api::utoipa::PartialSchema>::schema()
1132                            }
1133                        } else {
1134                            quote::quote! {
1135                                cinderblock_json_api::utoipa::openapi::RefOr::<cinderblock_json_api::utoipa::openapi::schema::Schema>::from(
1136                                    cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1137                                        .schema_type(
1138                                            cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1139                                                cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1140                                            ),
1141                                        )
1142                                        .property(
1143                                            "data",
1144                                            cinderblock_json_api::utoipa::openapi::schema::ArrayBuilder::new()
1145                                                .items(<#ident as cinderblock_json_api::utoipa::PartialSchema>::schema()),
1146                                        )
1147                                        .required("data")
1148                                )
1149                            }
1150                        };
1151
1152                        let summary_prefix = if is_paged { "Paged read" } else { "Read" };
1153
1154                        quote::quote! {
1155                            .path(
1156                                #full_path,
1157                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1158                                    #http_method,
1159                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1160                                        .operation_id(Some(#operation_id))
1161                                        .tag(#ident_str)
1162                                        .summary(Some(format!("{} {} via {}", #summary_prefix, #ident_str, #action_name_str)))
1163                                        #pk_parameter
1164                                        #(#query_params)*
1165                                        #paged_query_params
1166                                        .response(
1167                                            "200",
1168                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1169                                                .description(format!("Filtered list of {}s", #ident_str))
1170                                                .content(
1171                                                    "application/json",
1172                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1173                                                        .schema(Some(#response_schema))
1174                                                        .build(),
1175                                                )
1176                                                .build(),
1177                                        )
1178                                        .build(),
1179                                ),
1180                            )
1181                        }
1182                    }
1183                    }
1184                    ResourceActionInputKind::Create { accept } => {
1185                        let action_type_name = convert_case::ccase!(pascal, &action_name_str);
1186                        let input_type =
1187                            Ident::new(&format!("{action_type_name}Input"), route.action.span());
1188                        let fields = input_fields_for_accept(&resource.attributes, accept);
1189                        let body_required = !fields.is_empty();
1190
1191                        quote::quote! {
1192                            .path(
1193                                #full_path,
1194                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1195                                    #http_method,
1196                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1197                                        .operation_id(Some(#operation_id))
1198                                        .tag(#ident_str)
1199                                        .summary(Some(format!("Create {} via {}", #ident_str, #action_name_str)))
1200                                        #pk_parameter
1201                                        .request_body(Some(
1202                                            cinderblock_json_api::utoipa::openapi::request_body::RequestBodyBuilder::new()
1203                                                .content(
1204                                                    "application/json",
1205                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1206                                                        .schema(Some(<#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema()))
1207                                                        .build(),
1208                                                )
1209                                                .required(Some(
1210                                                    if #body_required {
1211                                                        cinderblock_json_api::utoipa::openapi::Required::True
1212                                                    } else {
1213                                                        cinderblock_json_api::utoipa::openapi::Required::False
1214                                                    },
1215                                                ))
1216                                                .build(),
1217                                        ))
1218                                        .response(
1219                                            "201",
1220                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1221                                                .description(format!("{} created", #ident_str))
1222                                                .content(
1223                                                    "application/json",
1224                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1225                                                        .schema(Some(
1226                                                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1227                                                                .schema_type(
1228                                                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1229                                                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1230                                                                    ),
1231                                                                )
1232                                                                .property(
1233                                                                    "data",
1234                                                                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
1235                                                                )
1236                                                                .required("data"),
1237                                                        ))
1238                                                        .build(),
1239                                                )
1240                                                .build(),
1241                                        )
1242                                        .build(),
1243                                ),
1244                            )
1245                        }
1246                    }
1247                    ResourceActionInputKind::Update(update) => {
1248                        let action_type_name = convert_case::ccase!(pascal, &action_name_str);
1249                        let input_type =
1250                            Ident::new(&format!("{action_type_name}Input"), route.action.span());
1251                        let fields = input_fields_for_accept(&resource.attributes, &update.accept);
1252                        let body_required = !fields.is_empty();
1253
1254                        quote::quote! {
1255                            .path(
1256                                #full_path,
1257                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1258                                    #http_method,
1259                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1260                                        .operation_id(Some(#operation_id))
1261                                        .tag(#ident_str)
1262                                        .summary(Some(format!("Update {} via {}", #ident_str, #action_name_str)))
1263                                        #pk_parameter
1264                                        .request_body(Some(
1265                                            cinderblock_json_api::utoipa::openapi::request_body::RequestBodyBuilder::new()
1266                                                .content(
1267                                                    "application/json",
1268                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1269                                                        .schema(Some(<#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema()))
1270                                                        .build(),
1271                                                )
1272                                                .required(Some(
1273                                                    if #body_required {
1274                                                        cinderblock_json_api::utoipa::openapi::Required::True
1275                                                    } else {
1276                                                        cinderblock_json_api::utoipa::openapi::Required::False
1277                                                    },
1278                                                ))
1279                                                .build(),
1280                                        ))
1281                                        .response(
1282                                            "200",
1283                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1284                                                .description(format!("{} updated", #ident_str))
1285                                                .content(
1286                                                    "application/json",
1287                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1288                                                        .schema(Some(
1289                                                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1290                                                                .schema_type(
1291                                                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1292                                                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1293                                                                    ),
1294                                                                )
1295                                                                .property(
1296                                                                    "data",
1297                                                                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
1298                                                                )
1299                                                                .required("data"),
1300                                                        ))
1301                                                        .build(),
1302                                                )
1303                                                .build(),
1304                                        )
1305                                        .build(),
1306                                ),
1307                            )
1308                        }
1309                    }
1310                    ResourceActionInputKind::Destroy => {
1311                        quote::quote! {
1312                            .path(
1313                                #full_path,
1314                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1315                                    #http_method,
1316                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1317                                        .operation_id(Some(#operation_id))
1318                                        .tag(#ident_str)
1319                                        .summary(Some(format!("Destroy {} via {}", #ident_str, #action_name_str)))
1320                                        #pk_parameter
1321                                        .response(
1322                                            "204",
1323                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1324                                                .description(format!("{} destroyed", #ident_str))
1325                                                .build(),
1326                                        )
1327                                        .build(),
1328                                ),
1329                            )
1330                        }
1331                    }
1332                }
1333            })
1334            .collect();
1335
1336        Some(quote::quote! {
1337            #resource_schema_impl
1338            #(#input_schema_impls)*
1339
1340            fn #openapi_fn_name() -> cinderblock_json_api::utoipa::openapi::OpenApi {
1341                cinderblock_json_api::utoipa::openapi::OpenApiBuilder::new()
1342                    .components(Some(
1343                        cinderblock_json_api::utoipa::openapi::ComponentsBuilder::new()
1344                            #resource_component
1345                            #(#input_components)*
1346                            .build(),
1347                    ))
1348                    .paths(
1349                        cinderblock_json_api::utoipa::openapi::PathsBuilder::new()
1350                            #(#path_items)*
1351                            .build(),
1352                    )
1353                    .build()
1354            }
1355        })
1356    } else {
1357        None
1358    };
1359
1360    // # Inventory submission
1361    //
1362    // The `openapi` field is populated when OpenAPI generation is enabled,
1363    // or set to `None` when the user disabled it with `openapi = false;`.
1364    let openapi_fn_name = Ident::new(&format!("__openapi_json_api_{name_slug}"), ident.span());
1365
1366    let openapi_field = if config.should_openapi() {
1367        quote::quote! { openapi: Some(#openapi_fn_name) }
1368    } else {
1369        quote::quote! { openapi: None }
1370    };
1371
1372    quote::quote! {
1373        fn #register_fn_name(
1374            mut router: cinderblock_json_api::axum::Router,
1375            ctx: ::std::sync::Arc<cinderblock_core::Context>,
1376        ) -> cinderblock_json_api::axum::Router {
1377            use cinderblock_json_api::axum::response::IntoResponse;
1378
1379            #(#route_registrations)*
1380
1381            router
1382        }
1383
1384        #openapi_impls
1385
1386        cinderblock_json_api::inventory::submit! {
1387            cinderblock_json_api::ResourceEndpoint {
1388                register: #register_fn_name,
1389                #openapi_field,
1390            }
1391        }
1392    }
1393    .into()
1394}