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_paged = action_read.paged.is_some();
422                    let has_user_arguments = !action_read.arguments.is_empty();
423                    let needs_arguments_struct = has_user_arguments || is_paged;
424
425                    let handler = if is_paged {
426                        // Paged reads always have an Arguments struct
427                        // (with at least page/per_page fields).
428                        let args_type =
429                            Ident::new(&format!("{action_type_name}Arguments"), route.action.span());
430                        quote::quote! {
431                            move |
432                                cinderblock_json_api::axum::extract::Query(args): cinderblock_json_api::axum::extract::Query<#args_type>,
433                            | {
434                                let ctx = ctx.clone();
435                                async move {
436                                    cinderblock_json_api::tracing::info!(
437                                        resource = stringify!(#ident),
438                                        action = #action_name_str,
439                                        "handling paged read request"
440                                    );
441
442                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &args).await {
443                                        Ok(result) => (
444                                            cinderblock_json_api::axum::http::StatusCode::OK,
445                                            cinderblock_json_api::axum::Json(
446                                                cinderblock_json_api::PaginatedResponse {
447                                                    data: result.data,
448                                                    meta: cinderblock_json_api::PaginationMeta {
449                                                        page: result.meta.page,
450                                                        per_page: result.meta.per_page,
451                                                        total: result.meta.total,
452                                                        total_pages: result.meta.total_pages,
453                                                    },
454                                                },
455                                            ),
456                                        )
457                                            .into_response(),
458                                        Err(err) => {
459                                            cinderblock_json_api::tracing::error!(
460                                                resource = stringify!(#ident),
461                                                action = #action_name_str,
462                                                error = %err,
463                                                "paged read request failed"
464                                            );
465                                            let status = match err.data() {
466                                                cinderblock_core::ListError::DataLayer(_) =>
467                                                    cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
468                                            };
469                                            (status, err.to_string()).into_response()
470                                        }
471                                    }
472                                }
473                            }
474                        }
475                    } else if needs_arguments_struct {
476                        let args_type =
477                            Ident::new(&format!("{action_type_name}Arguments"), route.action.span());
478                        quote::quote! {
479                            move |
480                                cinderblock_json_api::axum::extract::Query(args): cinderblock_json_api::axum::extract::Query<#args_type>,
481                            | {
482                                let ctx = ctx.clone();
483                                async move {
484                                    cinderblock_json_api::tracing::info!(
485                                        resource = stringify!(#ident),
486                                        action = #action_name_str,
487                                        "handling read request"
488                                    );
489
490                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &args).await {
491                                        Ok(results) => (
492                                            cinderblock_json_api::axum::http::StatusCode::OK,
493                                            cinderblock_json_api::axum::Json(
494                                                cinderblock_json_api::Response { data: results },
495                                            ),
496                                        )
497                                            .into_response(),
498                                        Err(err) => {
499                                            cinderblock_json_api::tracing::error!(
500                                                resource = stringify!(#ident),
501                                                action = #action_name_str,
502                                                error = %err,
503                                                "read request failed"
504                                            );
505                                            let status = match err.data() {
506                                                cinderblock_core::ListError::DataLayer(_) =>
507                                                    cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
508                                            };
509                                            (status, err.to_string()).into_response()
510                                        }
511                                    }
512                                }
513                            }
514                        }
515                    } else {
516                        quote::quote! {
517                            move || {
518                                let ctx = ctx.clone();
519                                async move {
520                                    cinderblock_json_api::tracing::info!(
521                                        resource = stringify!(#ident),
522                                        action = #action_name_str,
523                                        "handling read request"
524                                    );
525
526                                    match cinderblock_core::read::<#ident, #action_type>(&ctx, &()).await {
527                                        Ok(results) => (
528                                            cinderblock_json_api::axum::http::StatusCode::OK,
529                                            cinderblock_json_api::axum::Json(
530                                                cinderblock_json_api::Response { data: results },
531                                            ),
532                                        )
533                                            .into_response(),
534                                        Err(err) => {
535                                            cinderblock_json_api::tracing::error!(
536                                                resource = stringify!(#ident),
537                                                action = #action_name_str,
538                                                error = %err,
539                                                "read request failed"
540                                            );
541                                            let status = match err.data() {
542                                                cinderblock_core::ListError::DataLayer(_) =>
543                                                    cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
544                                            };
545                                            (status, err.to_string()).into_response()
546                                        }
547                                    }
548                                }
549                            }
550                        }
551                    };
552
553                    let routing_fn = route.method.axum_routing_fn();
554                    quote::quote! { #routing_fn(#handler) }
555                }
556                ResourceActionInputKind::Create { .. } => {
557                    let input_type =
558                        Ident::new(&format!("{action_type_name}Input"), route.action.span());
559
560                    let handler = quote::quote! {
561                        move |cinderblock_json_api::axum::Json(input): cinderblock_json_api::axum::Json<#input_type>| {
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 create request"
568                                );
569
570                                match cinderblock_core::create::<#ident, #action_type>(input, &ctx).await {
571                                    Ok(created) => (
572                                        cinderblock_json_api::axum::http::StatusCode::CREATED,
573                                        cinderblock_json_api::axum::Json(
574                                            cinderblock_json_api::Response { data: created },
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                                            "create request failed"
584                                        );
585                                        let status = match err.data() {
586                                            cinderblock_core::CreateError::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                    let routing_fn = route.method.axum_routing_fn();
597                    quote::quote! { #routing_fn(#handler) }
598                }
599                ResourceActionInputKind::Update(_) => {
600                    let input_type =
601                        Ident::new(&format!("{action_type_name}Input"), route.action.span());
602
603                    let handler = quote::quote! {
604                        move |
605                            cinderblock_json_api::axum::extract::Path(primary_key): cinderblock_json_api::axum::extract::Path<
606                                <#ident as cinderblock_core::Resource>::PrimaryKey,
607                            >,
608                            cinderblock_json_api::axum::Json(input): cinderblock_json_api::axum::Json<#input_type>,
609                        | {
610                            let ctx = ctx.clone();
611                            async move {
612                                cinderblock_json_api::tracing::info!(
613                                    resource = stringify!(#ident),
614                                    action = #action_name_str,
615                                    %primary_key,
616                                    "handling update request"
617                                );
618
619                                match cinderblock_core::update::<#ident, #action_type>(
620                                    &primary_key,
621                                    input,
622                                    &ctx,
623                                )
624                                .await
625                                {
626                                    Ok(updated) => (
627                                        cinderblock_json_api::axum::http::StatusCode::OK,
628                                        cinderblock_json_api::axum::Json(
629                                            cinderblock_json_api::Response { data: updated },
630                                        ),
631                                    )
632                                        .into_response(),
633                                    Err(err) => {
634                                        cinderblock_json_api::tracing::error!(
635                                            resource = stringify!(#ident),
636                                            action = #action_name_str,
637                                            error = %err,
638                                            "update request failed"
639                                        );
640                                        let status = match err.data() {
641                                            cinderblock_core::UpdateError::NotFound { .. } =>
642                                                cinderblock_json_api::axum::http::StatusCode::NOT_FOUND,
643                                            cinderblock_core::UpdateError::DataLayer(_) =>
644                                                cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
645                                        };
646                                        (status, err.to_string()).into_response()
647                                    }
648                                }
649                            }
650                        }
651                    };
652
653                    let routing_fn = route.method.axum_routing_fn();
654                    quote::quote! { #routing_fn(#handler) }
655                }
656                ResourceActionInputKind::Destroy => {
657                    let handler = quote::quote! {
658                        move |
659                            cinderblock_json_api::axum::extract::Path(primary_key): cinderblock_json_api::axum::extract::Path<
660                                <#ident as cinderblock_core::Resource>::PrimaryKey,
661                            >,
662                        | {
663                            let ctx = ctx.clone();
664                            async move {
665                                cinderblock_json_api::tracing::info!(
666                                    resource = stringify!(#ident),
667                                    action = #action_name_str,
668                                    %primary_key,
669                                    "handling destroy request"
670                                );
671
672                                match cinderblock_core::destroy::<#ident, #action_type>(
673                                    &primary_key,
674                                    &ctx,
675                                )
676                                .await
677                                {
678                                    Ok(_) => cinderblock_json_api::axum::http::StatusCode::NO_CONTENT
679                                        .into_response(),
680                                    Err(err) => {
681                                        cinderblock_json_api::tracing::error!(
682                                            resource = stringify!(#ident),
683                                            action = #action_name_str,
684                                            error = %err,
685                                            "destroy request failed"
686                                        );
687                                        let status = match err.data() {
688                                            cinderblock_core::DestroyError::NotFound { .. } =>
689                                                cinderblock_json_api::axum::http::StatusCode::NOT_FOUND,
690                                            cinderblock_core::DestroyError::DataLayer(_) =>
691                                                cinderblock_json_api::axum::http::StatusCode::INTERNAL_SERVER_ERROR,
692                                        };
693                                        (status, err.to_string()).into_response()
694                                    }
695                                }
696                            }
697                        }
698                    };
699
700                    let routing_fn = route.method.axum_routing_fn();
701                    quote::quote! { #routing_fn(#handler) }
702                }
703            };
704
705            quote::quote! {
706                {
707                    let ctx = ctx.clone();
708                    cinderblock_json_api::tracing::info!(
709                        resource = stringify!(#ident),
710                        action = #action_name_str,
711                        method = #method_str,
712                        route = #full_path,
713                        "registering JSON API endpoint"
714                    );
715                    router = router.route(
716                        #full_path,
717                        #handler_and_method,
718                    );
719                }
720            }
721        })
722        .collect();
723
724    // # OpenAPI generation
725    //
726    // When `openapi` is not explicitly disabled, we generate:
727    //
728    //   1. `PartialSchema` impl for the resource struct — builds an object
729    //      schema from all attributes
730    //   2. `PartialSchema` impls for each enabled action's input struct —
731    //      replicates the field selection logic from `cinderblock-core-macros`
732    //   3. An `openapi_fn` that builds an `OpenApi` spec fragment with
733    //      component schemas and path items for all enabled endpoints
734    //
735    // User-defined types (like `TicketStatus`) must implement `PartialSchema`
736    // themselves — we delegate via `<FieldType as PartialSchema>::schema()`.
737    let openapi_impls = if config.should_openapi() {
738        let ident_str = ident.to_string();
739
740        // # Resource struct schema
741        //
742        // Build an ObjectBuilder with a `.property()` + `.required()` call
743        // for each attribute. Each field's type schema is obtained via
744        // `<Type as PartialSchema>::schema()`.
745        let resource_schema_properties: Vec<_> = resource
746            .attributes
747            .iter()
748            .map(|attr| {
749                let field_name = attr.name.to_string();
750                let field_type = &attr.ty;
751                let is_optional = is_option_type(field_type);
752
753                let required_clause = if is_optional {
754                    quote::quote! {}
755                } else {
756                    quote::quote! { .required(#field_name) }
757                };
758
759                quote::quote! {
760                    .property(
761                        #field_name,
762                        <#field_type as cinderblock_json_api::FieldSchema>::field_schema(),
763                    )
764                    #required_clause
765                }
766            })
767            .collect();
768
769        let resource_schema_impl = quote::quote! {
770            impl cinderblock_json_api::utoipa::PartialSchema for #ident {
771                fn schema() -> cinderblock_json_api::utoipa::openapi::RefOr<
772                    cinderblock_json_api::utoipa::openapi::schema::Schema,
773                > {
774                    cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
775                        .schema_type(
776                            cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
777                                cinderblock_json_api::utoipa::openapi::schema::Type::Object,
778                            ),
779                        )
780                        #(#resource_schema_properties)*
781                        .into()
782                }
783            }
784
785            impl cinderblock_json_api::utoipa::ToSchema for #ident {
786                fn name() -> ::std::borrow::Cow<'static, str> {
787                    ::std::borrow::Cow::Borrowed(#ident_str)
788                }
789            }
790        };
791
792        // # Input struct schemas
793        //
794        // For each routed action that has an input struct (create and update
795        // actions), generate `PartialSchema` + `ToSchema` impls. Only actions
796        // that are actually routed get schemas.
797        let routed_action_names: HashSet<String> =
798            config.routes.iter().map(|r| r.action.to_string()).collect();
799
800        let input_schema_impls: Vec<_> = resource
801            .actions
802            .iter()
803            .filter_map(|action| {
804                let action_name_str = action.name.to_string();
805                if !routed_action_names.contains(&action_name_str) {
806                    return None;
807                }
808
809                let action_type_name = convert_case::ccase!(pascal, &action_name_str);
810                let input_type =
811                    Ident::new(&format!("{action_type_name}Input"), action.name.span());
812                let input_type_str = format!("{action_type_name}Input");
813
814                let accept = match &action.kind {
815                    ResourceActionInputKind::Create { accept } => accept,
816                    ResourceActionInputKind::Update(update) => &update.accept,
817                    _ => return None,
818                };
819
820                let fields = input_fields_for_accept(&resource.attributes, accept);
821
822                let properties: Vec<_> = fields
823                    .iter()
824                    .map(|(name, ty)| {
825                        let name_str = name.to_string();
826                        let is_optional = is_option_type(ty);
827
828                        let required_clause = if is_optional {
829                            quote::quote! {}
830                        } else {
831                            quote::quote! { .required(#name_str) }
832                        };
833
834                        quote::quote! {
835                            .property(
836                                #name_str,
837                                <#ty as cinderblock_json_api::FieldSchema>::field_schema(),
838                            )
839                            #required_clause
840                        }
841                    })
842                    .collect();
843
844                Some(quote::quote! {
845                    impl cinderblock_json_api::utoipa::PartialSchema for #input_type {
846                        fn schema() -> cinderblock_json_api::utoipa::openapi::RefOr<
847                            cinderblock_json_api::utoipa::openapi::schema::Schema,
848                        > {
849                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
850                                .schema_type(
851                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
852                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
853                                    ),
854                                )
855                                #(#properties)*
856                                .into()
857                        }
858                    }
859
860                    impl cinderblock_json_api::utoipa::ToSchema for #input_type {
861                        fn name() -> ::std::borrow::Cow<'static, str> {
862                            ::std::borrow::Cow::Borrowed(#input_type_str)
863                        }
864                    }
865                })
866            })
867            .collect();
868
869        // # OpenAPI spec function
870        //
871        // Builds a complete `OpenApi` fragment containing:
872        //   - Component schemas for the resource and all input structs
873        //   - Path items with operations for each declared route
874        //   - Request/response body schemas referencing the components
875        //   - Tags based on the resource struct name
876        let openapi_fn_name = Ident::new(&format!("__openapi_json_api_{name_slug}"), ident.span());
877
878        // Schema component registrations for the spec.
879        let resource_component = {
880            let ident_str_val = ident.to_string();
881            quote::quote! {
882                .schema(
883                    #ident_str_val,
884                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
885                )
886            }
887        };
888
889        let input_components: Vec<_> = resource
890            .actions
891            .iter()
892            .filter_map(|action| {
893                let action_name_str = action.name.to_string();
894                if !routed_action_names.contains(&action_name_str) {
895                    return None;
896                }
897
898                let action_type_name = convert_case::ccase!(pascal, &action_name_str);
899                let input_type =
900                    Ident::new(&format!("{action_type_name}Input"), action.name.span());
901                let input_type_str = format!("{action_type_name}Input");
902
903                // Only create/update actions have input structs.
904                match &action.kind {
905                    ResourceActionInputKind::Create { .. } | ResourceActionInputKind::Update(_) => {
906                    }
907                    _ => return None,
908                }
909
910                Some(quote::quote! {
911                    .schema(
912                        #input_type_str,
913                        <#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema(),
914                    )
915                })
916            })
917            .collect();
918
919        // # Path items for each declared route
920        //
921        // Each route declaration produces one OpenAPI path item. The action
922        // kind determines the response shape (read returns Vec, create/update
923        // returns single, destroy returns 204).
924        let ident_kebab = convert_case::ccase!(kebab, ident.to_string());
925
926        let path_items: Vec<_> = config
927            .routes
928            .iter()
929            .map(|route| {
930                let action_name_str = route.action.to_string();
931                let full_path = format!("{}{}", base_path, route.path.value());
932                let action_path_kebab = convert_case::ccase!(kebab, &action_name_str);
933                let http_method = route.method.openapi_http_method();
934                let method_lower = route.method.as_str().to_lowercase();
935                let operation_id = format!("{}-{}-{}", method_lower, ident_kebab, action_path_kebab);
936
937                let action_def = resource
938                    .actions
939                    .iter()
940                    .find(|a| a.name == route.action)
941                    .expect("action existence validated above");
942
943                // Find the primary key type for path parameter schemas.
944                let pk_type = resource
945                    .attributes
946                    .iter()
947                    .find(|a| a.primary_key.value())
948                    .map(|a| &a.ty);
949
950                // Generate a path parameter for {primary_key} if the route
951                // path contains it.
952                let pk_parameter = if route.path.value().contains("{primary_key}") {
953                    pk_type.map(|ty| {
954                        quote::quote! {
955                            .parameter(
956                                cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
957                                    .name("primary_key")
958                                    .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Path)
959                                    .required(cinderblock_json_api::utoipa::openapi::Required::True)
960                                    .schema(Some(<#ty as cinderblock_json_api::FieldSchema>::field_schema()))
961                                    .build(),
962                            )
963                        }
964                    })
965                } else {
966                    None
967                };
968
969                match &action_def.kind {
970                    ResourceActionInputKind::Read(action_read) => {
971                        let is_paged = action_read.paged.is_some();
972
973                        // Query parameters for read action arguments.
974                        let query_params: Vec<_> = action_read.arguments.iter().map(|arg| {
975                            let arg_name_str = arg.name.to_string();
976                            let arg_name_kebab = convert_case::ccase!(kebab, &arg_name_str);
977                            let is_optional = is_option_type(&arg.ty);
978
979                            let schema_type = if is_optional {
980                                extract_option_inner_type(&arg.ty).unwrap_or(&arg.ty)
981                            } else {
982                                &arg.ty
983                            };
984
985                            let required_value = if is_optional {
986                                quote::quote! { cinderblock_json_api::utoipa::openapi::Required::False }
987                            } else {
988                                quote::quote! { cinderblock_json_api::utoipa::openapi::Required::True }
989                            };
990
991                            quote::quote! {
992                                .parameter(
993                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
994                                        .name(#arg_name_kebab)
995                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
996                                        .required(#required_value)
997                                        .schema(Some(<#schema_type as cinderblock_json_api::FieldSchema>::field_schema()))
998                                        .build(),
999                                )
1000                            }
1001                        }).collect();
1002
1003                        // For paged reads, add `page` and `per_page` query params
1004                        // to the OpenAPI spec.
1005                        let paged_query_params = if is_paged {
1006                            quote::quote! {
1007                                .parameter(
1008                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
1009                                        .name("page")
1010                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
1011                                        .required(cinderblock_json_api::utoipa::openapi::Required::False)
1012                                        .schema(Some(<u32 as cinderblock_json_api::FieldSchema>::field_schema()))
1013                                        .description(Some("Page number (1-indexed, default: 1)"))
1014                                        .build(),
1015                                )
1016                                .parameter(
1017                                    cinderblock_json_api::utoipa::openapi::path::ParameterBuilder::new()
1018                                        .name("per_page")
1019                                        .parameter_in(cinderblock_json_api::utoipa::openapi::path::ParameterIn::Query)
1020                                        .required(cinderblock_json_api::utoipa::openapi::Required::False)
1021                                        .schema(Some(<u32 as cinderblock_json_api::FieldSchema>::field_schema()))
1022                                        .description(Some("Items per page"))
1023                                        .build(),
1024                                )
1025                            }
1026                        } else {
1027                            quote::quote! {}
1028                        };
1029
1030                        // Response schema differs: paged reads include meta,
1031                        // non-paged reads return { data: [...] }.
1032                        let response_schema = if is_paged {
1033                            quote::quote! {
1034                                <cinderblock_json_api::PaginatedResponse<#ident> as cinderblock_json_api::utoipa::PartialSchema>::schema()
1035                            }
1036                        } else {
1037                            quote::quote! {
1038                                cinderblock_json_api::utoipa::openapi::RefOr::<cinderblock_json_api::utoipa::openapi::schema::Schema>::from(
1039                                    cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1040                                        .schema_type(
1041                                            cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1042                                                cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1043                                            ),
1044                                        )
1045                                        .property(
1046                                            "data",
1047                                            cinderblock_json_api::utoipa::openapi::schema::ArrayBuilder::new()
1048                                                .items(<#ident as cinderblock_json_api::utoipa::PartialSchema>::schema()),
1049                                        )
1050                                        .required("data")
1051                                )
1052                            }
1053                        };
1054
1055                        let summary_prefix = if is_paged { "Paged read" } else { "Read" };
1056
1057                        quote::quote! {
1058                            .path(
1059                                #full_path,
1060                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1061                                    #http_method,
1062                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1063                                        .operation_id(Some(#operation_id))
1064                                        .tag(#ident_str)
1065                                        .summary(Some(format!("{} {} via {}", #summary_prefix, #ident_str, #action_name_str)))
1066                                        #pk_parameter
1067                                        #(#query_params)*
1068                                        #paged_query_params
1069                                        .response(
1070                                            "200",
1071                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1072                                                .description(format!("Filtered list of {}s", #ident_str))
1073                                                .content(
1074                                                    "application/json",
1075                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1076                                                        .schema(Some(#response_schema))
1077                                                        .build(),
1078                                                )
1079                                                .build(),
1080                                        )
1081                                        .build(),
1082                                ),
1083                            )
1084                        }
1085                    }
1086                    ResourceActionInputKind::Create { accept } => {
1087                        let action_type_name = convert_case::ccase!(pascal, &action_name_str);
1088                        let input_type =
1089                            Ident::new(&format!("{action_type_name}Input"), route.action.span());
1090                        let fields = input_fields_for_accept(&resource.attributes, accept);
1091                        let body_required = !fields.is_empty();
1092
1093                        quote::quote! {
1094                            .path(
1095                                #full_path,
1096                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1097                                    #http_method,
1098                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1099                                        .operation_id(Some(#operation_id))
1100                                        .tag(#ident_str)
1101                                        .summary(Some(format!("Create {} via {}", #ident_str, #action_name_str)))
1102                                        #pk_parameter
1103                                        .request_body(Some(
1104                                            cinderblock_json_api::utoipa::openapi::request_body::RequestBodyBuilder::new()
1105                                                .content(
1106                                                    "application/json",
1107                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1108                                                        .schema(Some(<#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema()))
1109                                                        .build(),
1110                                                )
1111                                                .required(Some(
1112                                                    if #body_required {
1113                                                        cinderblock_json_api::utoipa::openapi::Required::True
1114                                                    } else {
1115                                                        cinderblock_json_api::utoipa::openapi::Required::False
1116                                                    },
1117                                                ))
1118                                                .build(),
1119                                        ))
1120                                        .response(
1121                                            "201",
1122                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1123                                                .description(format!("{} created", #ident_str))
1124                                                .content(
1125                                                    "application/json",
1126                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1127                                                        .schema(Some(
1128                                                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1129                                                                .schema_type(
1130                                                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1131                                                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1132                                                                    ),
1133                                                                )
1134                                                                .property(
1135                                                                    "data",
1136                                                                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
1137                                                                )
1138                                                                .required("data"),
1139                                                        ))
1140                                                        .build(),
1141                                                )
1142                                                .build(),
1143                                        )
1144                                        .build(),
1145                                ),
1146                            )
1147                        }
1148                    }
1149                    ResourceActionInputKind::Update(update) => {
1150                        let action_type_name = convert_case::ccase!(pascal, &action_name_str);
1151                        let input_type =
1152                            Ident::new(&format!("{action_type_name}Input"), route.action.span());
1153                        let fields = input_fields_for_accept(&resource.attributes, &update.accept);
1154                        let body_required = !fields.is_empty();
1155
1156                        quote::quote! {
1157                            .path(
1158                                #full_path,
1159                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1160                                    #http_method,
1161                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1162                                        .operation_id(Some(#operation_id))
1163                                        .tag(#ident_str)
1164                                        .summary(Some(format!("Update {} via {}", #ident_str, #action_name_str)))
1165                                        #pk_parameter
1166                                        .request_body(Some(
1167                                            cinderblock_json_api::utoipa::openapi::request_body::RequestBodyBuilder::new()
1168                                                .content(
1169                                                    "application/json",
1170                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1171                                                        .schema(Some(<#input_type as cinderblock_json_api::utoipa::PartialSchema>::schema()))
1172                                                        .build(),
1173                                                )
1174                                                .required(Some(
1175                                                    if #body_required {
1176                                                        cinderblock_json_api::utoipa::openapi::Required::True
1177                                                    } else {
1178                                                        cinderblock_json_api::utoipa::openapi::Required::False
1179                                                    },
1180                                                ))
1181                                                .build(),
1182                                        ))
1183                                        .response(
1184                                            "200",
1185                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1186                                                .description(format!("{} updated", #ident_str))
1187                                                .content(
1188                                                    "application/json",
1189                                                    cinderblock_json_api::utoipa::openapi::ContentBuilder::new()
1190                                                        .schema(Some(
1191                                                            cinderblock_json_api::utoipa::openapi::schema::ObjectBuilder::new()
1192                                                                .schema_type(
1193                                                                    cinderblock_json_api::utoipa::openapi::schema::SchemaType::new(
1194                                                                        cinderblock_json_api::utoipa::openapi::schema::Type::Object,
1195                                                                    ),
1196                                                                )
1197                                                                .property(
1198                                                                    "data",
1199                                                                    <#ident as cinderblock_json_api::utoipa::PartialSchema>::schema(),
1200                                                                )
1201                                                                .required("data"),
1202                                                        ))
1203                                                        .build(),
1204                                                )
1205                                                .build(),
1206                                        )
1207                                        .build(),
1208                                ),
1209                            )
1210                        }
1211                    }
1212                    ResourceActionInputKind::Destroy => {
1213                        quote::quote! {
1214                            .path(
1215                                #full_path,
1216                                cinderblock_json_api::utoipa::openapi::PathItem::new(
1217                                    #http_method,
1218                                    cinderblock_json_api::utoipa::openapi::path::OperationBuilder::new()
1219                                        .operation_id(Some(#operation_id))
1220                                        .tag(#ident_str)
1221                                        .summary(Some(format!("Destroy {} via {}", #ident_str, #action_name_str)))
1222                                        #pk_parameter
1223                                        .response(
1224                                            "204",
1225                                            cinderblock_json_api::utoipa::openapi::ResponseBuilder::new()
1226                                                .description(format!("{} destroyed", #ident_str))
1227                                                .build(),
1228                                        )
1229                                        .build(),
1230                                ),
1231                            )
1232                        }
1233                    }
1234                }
1235            })
1236            .collect();
1237
1238        Some(quote::quote! {
1239            #resource_schema_impl
1240            #(#input_schema_impls)*
1241
1242            fn #openapi_fn_name() -> cinderblock_json_api::utoipa::openapi::OpenApi {
1243                cinderblock_json_api::utoipa::openapi::OpenApiBuilder::new()
1244                    .components(Some(
1245                        cinderblock_json_api::utoipa::openapi::ComponentsBuilder::new()
1246                            #resource_component
1247                            #(#input_components)*
1248                            .build(),
1249                    ))
1250                    .paths(
1251                        cinderblock_json_api::utoipa::openapi::PathsBuilder::new()
1252                            #(#path_items)*
1253                            .build(),
1254                    )
1255                    .build()
1256            }
1257        })
1258    } else {
1259        None
1260    };
1261
1262    // # Inventory submission
1263    //
1264    // The `openapi` field is populated when OpenAPI generation is enabled,
1265    // or set to `None` when the user disabled it with `openapi = false;`.
1266    let openapi_fn_name = Ident::new(&format!("__openapi_json_api_{name_slug}"), ident.span());
1267
1268    let openapi_field = if config.should_openapi() {
1269        quote::quote! { openapi: Some(#openapi_fn_name) }
1270    } else {
1271        quote::quote! { openapi: None }
1272    };
1273
1274    quote::quote! {
1275        fn #register_fn_name(
1276            mut router: cinderblock_json_api::axum::Router,
1277            ctx: ::std::sync::Arc<cinderblock_core::Context>,
1278        ) -> cinderblock_json_api::axum::Router {
1279            use cinderblock_json_api::axum::response::IntoResponse;
1280
1281            #(#route_registrations)*
1282
1283            router
1284        }
1285
1286        #openapi_impls
1287
1288        cinderblock_json_api::inventory::submit! {
1289            cinderblock_json_api::ResourceEndpoint {
1290                register: #register_fn_name,
1291                #openapi_field,
1292            }
1293        }
1294    }
1295    .into()
1296}