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