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}