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