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