Skip to main content

cinderblock_json_api_macros/
lib.rs

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