Skip to main content

cinderblock_json_api/
lib.rs

1//! JSON REST API extension for cinderblock resources.
2//!
3//! When a resource declares `cinderblock_json_api` in its `extensions { ... }`
4//! block, the [`resource!`](cinderblock_core::resource) macro generates Axum
5//! route handlers and endpoint registration code. At startup, all registered
6//! endpoints are automatically discovered via [`inventory`] and assembled into
7//! an [`axum::Router`].
8//!
9//! # Extension configuration
10//!
11//! Inside the `extensions` block of a [`resource!`](cinderblock_core::resource)
12//! invocation, declare routes and optional settings:
13//!
14//! ```rust,ignore
15//! extensions {
16//!     cinderblock_json_api {
17//!         // Each `route` maps an HTTP method + path to a resource action.
18//!         route = { method = GET;    path = "/";              action = all;    };
19//!         route = { method = POST;   path = "/";              action = open;   };
20//!         route = { method = POST;   path = "/assign";        action = assign; };
21//!         route = { method = PATCH;  path = "/{primary_key}"; action = close;  };
22//!         route = { method = DELETE; path = "/{primary_key}"; action = remove; };
23//!
24//!         // Optional: override the auto-derived base path.
25//!         // Default: kebab-case of resource name segments joined by `/`.
26//!         //   e.g. `Helpdesk.Support.Ticket` -> `/helpdesk/support/ticket`
27//!         // base_path = "/api/v1/tickets";
28//!
29//!         // Optional: disable OpenAPI spec generation. Default: true.
30//!         // openapi = false;
31//!     };
32//! }
33//! ```
34//!
35//! ## Route configuration
36//!
37//! | Field | Required | Description |
38//! |---|---|---|
39//! | `method` | yes | HTTP method: `GET`, `POST`, `PATCH`, `PUT`, or `DELETE` |
40//! | `path` | yes | Path relative to the base path. Use `/{primary_key}` for routes that operate on a single resource. |
41//! | `action` | yes | Name of a declared action on the resource. Must match the action kind (e.g. `GET` for `read`, `POST` for `create`). |
42//!
43//! The action name must refer to an action declared in the resource's `actions`
44//! block. Duplicate method + path combinations are rejected at compile time.
45//!
46//! ## Route behavior by action kind
47//!
48//! - **Read** (`GET`): query parameters are deserialized into the action's
49//!   `Arguments` struct. Returns `{ "data": [...] }`.
50//! - **Create** (`POST`): JSON body is deserialized into the action's `Input`
51//!   struct. Returns `{ "data": <resource> }`.
52//! - **Update** (`PATCH`/`PUT`): primary key is extracted from the URL path,
53//!   JSON body is deserialized into the action's `Input` struct. Returns
54//!   `{ "data": <resource> }`.
55//! - **Destroy** (`DELETE`): primary key is extracted from the URL path.
56//!   Returns `{ "data": <resource> }` with the deleted resource.
57//!
58//! All responses are wrapped in a [`Response`] envelope (`{ "data": ... }`).
59//!
60//! ## OpenAPI and Swagger UI
61//!
62//! By default, the extension generates an OpenAPI spec fragment for each
63//! resource. These fragments are merged and served at `GET /openapi.json`.
64//!
65//! When the `swagger-ui` feature is enabled, a Swagger UI is mounted at
66//! `/swagger-ui`. This can be toggled off via [`RouterConfig::swagger_ui`].
67//!
68//! ## Custom types in OpenAPI schemas
69//!
70//! The generated OpenAPI schemas use the [`FieldSchema`] trait to produce
71//! schemas for each attribute type. Built-in types (`String`, integers, `bool`,
72//! `Uuid`) have implementations provided. For custom types (like enums),
73//! derive [`utoipa::ToSchema`] and bridge it with [`impl_field_schema!`]:
74//!
75//! ```rust,ignore
76//! #[derive(Debug, Clone, Serialize, Deserialize, cinderblock_json_api::utoipa::ToSchema)]
77//! enum TicketStatus {
78//!     Open,
79//!     Closed,
80//! }
81//!
82//! cinderblock_json_api::impl_field_schema!(TicketStatus);
83//! ```
84//!
85//! # Building the router
86//!
87//! Use [`router()`] for the common case, or [`RouterConfig`] for more control:
88//!
89//! ```rust,ignore
90//! let ctx = cinderblock_core::Context::new();
91//!
92//! // Simple — all defaults.
93//! let app = cinderblock_json_api::router(ctx);
94//!
95//! // Or configure options like Swagger UI.
96//! let app = cinderblock_json_api::RouterConfig::new(ctx)
97//!     .swagger_ui(false)
98//!     .build();
99//!
100//! let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
101//! axum::serve(listener, app).await?;
102//! ```
103//!
104//! # Full example
105//!
106//! ```rust,ignore
107//! use cinderblock_core::{Context, resource, serde::{Deserialize, Serialize}};
108//! use uuid::Uuid;
109//!
110//! resource! {
111//!     name = Helpdesk.Support.Ticket;
112//!
113//!     attributes {
114//!         ticket_id Uuid {
115//!             primary_key true;
116//!             writable false;
117//!             default || Uuid::new_v4();
118//!         }
119//!         subject String;
120//!         status TicketStatus;
121//!     }
122//!
123//!     actions {
124//!         read all {
125//!             argument { status: Option<TicketStatus> };
126//!             filter { status == arg(status) };
127//!         };
128//!         create open;
129//!         update close {
130//!             accept [];
131//!             change_ref |ticket| { ticket.status = TicketStatus::Closed; };
132//!         };
133//!         destroy remove;
134//!     }
135//!
136//!     extensions {
137//!         cinderblock_json_api {
138//!             route = { method = GET;    path = "/";              action = all;    };
139//!             route = { method = POST;   path = "/";              action = open;   };
140//!             route = { method = PATCH;  path = "/{primary_key}"; action = close;  };
141//!             route = { method = DELETE; path = "/{primary_key}"; action = remove; };
142//!         };
143//!     }
144//! }
145//!
146//! #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq,
147//!          cinderblock_json_api::utoipa::ToSchema)]
148//! enum TicketStatus { #[default] Open, Closed }
149//! cinderblock_json_api::impl_field_schema!(TicketStatus);
150//!
151//! #[tokio::main]
152//! async fn main() -> cinderblock_core::Result<()> {
153//!     let ctx = Context::new();
154//!     let router = cinderblock_json_api::router(ctx);
155//!     let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
156//!     axum::serve(listener, router).await?;
157//!     Ok(())
158//! }
159//! ```
160
161use std::sync::Arc;
162
163pub use serde;
164
165// Re-export dependencies for macro hygiene — the generated code from
166// `cinderblock-json-api-macros` references these through `cinderblock_json_api::axum`,
167// `cinderblock_json_api::tracing`, etc., so they must be available at the
168// call site without the user adding them as direct dependencies.
169pub use axum;
170pub use inventory;
171pub use tracing;
172pub use utoipa;
173
174// Re-export the extension proc macro so `resource!` can call
175// `cinderblock_json_api::__resource_extension!`.
176pub use cinderblock_json_api_macros::__resource_extension;
177
178/// Helper trait that provides OpenAPI schema generation for types used as
179/// resource attribute fields.
180///
181/// This exists because `utoipa::PartialSchema` is a foreign trait, so we
182/// can't impl it for foreign types like `uuid::Uuid` due to orphan rules.
183/// The extension macro generates calls to
184/// `<Type as cinderblock_json_api::FieldSchema>::field_schema()` instead of
185/// `<Type as utoipa::PartialSchema>::schema()`.
186///
187/// Types that derive `utoipa::ToSchema` (which implies `PartialSchema`)
188/// can use the blanket impl via the `partial_schema_field_schema!` macro.
189/// Common built-in types (`String`, integers, `bool`, `Uuid`) have
190/// explicit impls provided here.
191pub trait FieldSchema {
192    fn field_schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>;
193}
194
195/// Implements `FieldSchema` for types that already have `PartialSchema`.
196///
197/// Users call this for their own types that derive `ToSchema`:
198/// ```rust,ignore
199/// #[derive(utoipa::ToSchema)]
200/// enum TicketStatus { Open, Closed }
201/// cinderblock_json_api::impl_field_schema!(TicketStatus);
202/// ```
203#[macro_export]
204macro_rules! impl_field_schema {
205    ($ty:ty) => {
206        impl $crate::FieldSchema for $ty {
207            fn field_schema()
208            -> $crate::utoipa::openapi::RefOr<$crate::utoipa::openapi::schema::Schema> {
209                <$ty as $crate::utoipa::PartialSchema>::schema()
210            }
211        }
212    };
213}
214
215// # Built-in FieldSchema implementations
216//
217// These cover the common Rust types that appear as resource attribute
218// fields. The schemas match what utoipa's built-in `ComposeSchema` impls
219// would produce.
220
221macro_rules! impl_field_schema_string {
222    ($($ty:ty),*) => {
223        $(
224            impl FieldSchema for $ty {
225                fn field_schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
226                    use utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
227                    ObjectBuilder::new()
228                        .schema_type(SchemaType::new(Type::String))
229                        .into()
230                }
231            }
232        )*
233    };
234}
235
236macro_rules! impl_field_schema_integer {
237    ($($ty:ty => $format:expr),*) => {
238        $(
239            impl FieldSchema for $ty {
240                fn field_schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
241                    use utoipa::openapi::schema::{ObjectBuilder, SchemaType, SchemaFormat, Type};
242                    ObjectBuilder::new()
243                        .schema_type(SchemaType::new(Type::Integer))
244                        .format(Some(SchemaFormat::KnownFormat($format)))
245                        .into()
246                }
247            }
248        )*
249    };
250}
251
252macro_rules! impl_field_schema_number {
253    ($($ty:ty => $format:expr),*) => {
254        $(
255            impl FieldSchema for $ty {
256                fn field_schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
257                    use utoipa::openapi::schema::{ObjectBuilder, SchemaType, SchemaFormat, Type};
258                    ObjectBuilder::new()
259                        .schema_type(SchemaType::new(Type::Number))
260                        .format(Some(SchemaFormat::KnownFormat($format)))
261                        .into()
262                }
263            }
264        )*
265    };
266}
267
268impl_field_schema_string!(String);
269
270impl FieldSchema for bool {
271    fn field_schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
272        use utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
273        ObjectBuilder::new()
274            .schema_type(SchemaType::new(Type::Boolean))
275            .into()
276    }
277}
278
279impl_field_schema_integer!(
280    i8 => utoipa::openapi::KnownFormat::Int32,
281    i16 => utoipa::openapi::KnownFormat::Int32,
282    i32 => utoipa::openapi::KnownFormat::Int32,
283    i64 => utoipa::openapi::KnownFormat::Int64,
284    u8 => utoipa::openapi::KnownFormat::Int32,
285    u16 => utoipa::openapi::KnownFormat::Int32,
286    u32 => utoipa::openapi::KnownFormat::Int32,
287    u64 => utoipa::openapi::KnownFormat::Int64,
288    isize => utoipa::openapi::KnownFormat::Int64,
289    usize => utoipa::openapi::KnownFormat::Int64
290);
291
292impl_field_schema_number!(
293    f32 => utoipa::openapi::KnownFormat::Float,
294    f64 => utoipa::openapi::KnownFormat::Double
295);
296
297impl FieldSchema for uuid::Uuid {
298    fn field_schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
299        use utoipa::openapi::schema::{ObjectBuilder, SchemaFormat, SchemaType, Type};
300        ObjectBuilder::new()
301            .schema_type(SchemaType::new(Type::String))
302            .format(Some(SchemaFormat::KnownFormat(
303                utoipa::openapi::KnownFormat::Uuid,
304            )))
305            .into()
306    }
307}
308
309impl<T: FieldSchema> FieldSchema for Option<T> {
310    fn field_schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
311        use utoipa::openapi::schema::{Schema, SchemaType, Type};
312
313        let inner = T::field_schema();
314
315        match inner {
316            utoipa::openapi::RefOr::T(Schema::Object(mut obj)) => {
317                // Add `Type::Null` to make the schema nullable in OpenAPI 3.1
318                let nullable_type = match obj.schema_type {
319                    SchemaType::Type(t) => SchemaType::Array(vec![t, Type::Null]),
320                    SchemaType::Array(mut types) => {
321                        types.push(Type::Null);
322                        SchemaType::Array(types)
323                    }
324                    SchemaType::AnyValue => SchemaType::AnyValue,
325                };
326                obj.schema_type = nullable_type;
327                obj.into()
328            }
329            other => other,
330        }
331    }
332}
333
334/// Generic JSON API response envelope.
335///
336/// Wraps all responses in a `{ "data": ... }` structure so the format is
337/// extensible with future fields like pagination, links, or errors.
338///
339/// For list endpoints `T` is `Vec<R>`, for single-resource endpoints it
340/// will be `R` directly.
341#[derive(Debug, serde::Serialize)]
342pub struct Response<T: serde::Serialize> {
343    pub data: T,
344}
345
346/// JSON API response envelope for paginated list endpoints.
347///
348/// Returns `{ "data": [...], "meta": { page, per_page, total, total_pages } }`.
349/// Used by paged read action handlers instead of the plain [`Response`] envelope.
350#[derive(Debug, serde::Serialize)]
351pub struct PaginatedResponse<T: serde::Serialize> {
352    pub data: Vec<T>,
353    pub meta: PaginationMeta,
354}
355
356/// Pagination metadata included in [`PaginatedResponse`].
357#[derive(Debug, serde::Serialize)]
358pub struct PaginationMeta {
359    pub page: u32,
360    pub per_page: u32,
361    pub total: u64,
362    pub total_pages: u32,
363}
364
365// # PartialSchema / ToSchema for Response<T>
366//
367// Manual implementations so the generated OpenAPI spec can describe the
368// `{ "data": ... }` envelope without requiring a derive on a struct that
369// has a generic type parameter. The schema delegates to `T`'s schema for
370// the `data` property.
371impl<T> utoipa::PartialSchema for Response<T>
372where
373    T: serde::Serialize + utoipa::PartialSchema,
374{
375    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
376        use utoipa::openapi::schema::{ObjectBuilder, SchemaType, Type};
377
378        ObjectBuilder::new()
379            .schema_type(SchemaType::new(Type::Object))
380            .property("data", T::schema())
381            .required("data")
382            .into()
383    }
384}
385
386impl<T> utoipa::ToSchema for Response<T>
387where
388    T: serde::Serialize + utoipa::PartialSchema,
389{
390    fn name() -> std::borrow::Cow<'static, str> {
391        std::borrow::Cow::Borrowed("Response")
392    }
393}
394
395// # PartialSchema / ToSchema for PaginatedResponse<T>
396//
397// Describes the `{ "data": [...], "meta": {...} }` shape for OpenAPI specs.
398impl<T> utoipa::PartialSchema for PaginatedResponse<T>
399where
400    T: serde::Serialize + utoipa::PartialSchema,
401{
402    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
403        use utoipa::openapi::schema::{
404            ArrayBuilder, ObjectBuilder, SchemaFormat, SchemaType, Type,
405        };
406
407        let meta_schema = ObjectBuilder::new()
408            .schema_type(SchemaType::new(Type::Object))
409            .property(
410                "page",
411                ObjectBuilder::new()
412                    .schema_type(SchemaType::new(Type::Integer))
413                    .format(Some(SchemaFormat::KnownFormat(
414                        utoipa::openapi::KnownFormat::Int32,
415                    ))),
416            )
417            .required("page")
418            .property(
419                "per_page",
420                ObjectBuilder::new()
421                    .schema_type(SchemaType::new(Type::Integer))
422                    .format(Some(SchemaFormat::KnownFormat(
423                        utoipa::openapi::KnownFormat::Int32,
424                    ))),
425            )
426            .required("per_page")
427            .property(
428                "total",
429                ObjectBuilder::new()
430                    .schema_type(SchemaType::new(Type::Integer))
431                    .format(Some(SchemaFormat::KnownFormat(
432                        utoipa::openapi::KnownFormat::Int64,
433                    ))),
434            )
435            .required("total")
436            .property(
437                "total_pages",
438                ObjectBuilder::new()
439                    .schema_type(SchemaType::new(Type::Integer))
440                    .format(Some(SchemaFormat::KnownFormat(
441                        utoipa::openapi::KnownFormat::Int32,
442                    ))),
443            )
444            .required("total_pages");
445
446        ObjectBuilder::new()
447            .schema_type(SchemaType::new(Type::Object))
448            .property("data", ArrayBuilder::new().items(T::schema()))
449            .required("data")
450            .property("meta", meta_schema)
451            .required("meta")
452            .into()
453    }
454}
455
456impl<T> utoipa::ToSchema for PaginatedResponse<T>
457where
458    T: serde::Serialize + utoipa::PartialSchema,
459{
460    fn name() -> std::borrow::Cow<'static, str> {
461        std::borrow::Cow::Borrowed("PaginatedResponse")
462    }
463}
464
465/// A registered resource endpoint. Extension macros generate instances of this
466/// struct and submit them via `inventory::submit!`. The `register` function
467/// takes an existing router and context, and returns a new router with the
468/// resource's endpoints added.
469///
470/// The optional `openapi` function returns an OpenAPI spec fragment for the
471/// resource's endpoints. When present, the router builder merges all fragments
472/// into a single spec served at `/openapi.json`.
473pub struct ResourceEndpoint {
474    pub register: fn(axum::Router, Arc<cinderblock_core::Context>) -> axum::Router,
475    pub openapi: Option<fn() -> utoipa::openapi::OpenApi>,
476}
477
478inventory::collect!(ResourceEndpoint);
479
480/// Configuration builder for the JSON API router.
481///
482/// Allows controlling optional features like Swagger UI before building
483/// the final `axum::Router`.
484///
485/// ```rust,ignore
486/// let router = cinderblock_json_api::RouterConfig::new(ctx)
487///     .swagger_ui(true)
488///     .build();
489/// ```
490pub struct RouterConfig {
491    ctx: Arc<cinderblock_core::Context>,
492    swagger_ui: bool,
493}
494
495impl RouterConfig {
496    pub fn new(ctx: impl Into<Arc<cinderblock_core::Context>>) -> Self {
497        Self {
498            ctx: ctx.into(),
499            swagger_ui: true,
500        }
501    }
502
503    /// Enable or disable the Swagger UI endpoint at `/swagger-ui`.
504    /// Only takes effect when the `utoipa-swagger-ui` feature is enabled.
505    /// Default: `true`.
506    pub fn swagger_ui(mut self, enabled: bool) -> Self {
507        self.swagger_ui = enabled;
508        self
509    }
510
511    pub fn build(self) -> axum::Router {
512        let mut router = axum::Router::new();
513
514        // # Endpoint registration + OpenAPI spec collection
515        //
516        // Each resource that declared `cinderblock_json_api` in its extensions block
517        // contributes both route handlers and an optional OpenAPI spec
518        // fragment. We collect the fragments and merge them afterward.
519        let mut openapi_specs: Vec<utoipa::openapi::OpenApi> = Vec::new();
520
521        for endpoint in inventory::iter::<ResourceEndpoint> {
522            router = (endpoint.register)(router, self.ctx.clone());
523
524            if let Some(openapi_fn) = endpoint.openapi {
525                openapi_specs.push(openapi_fn());
526            }
527        }
528
529        // # OpenAPI spec merging
530        //
531        // Build a base spec and merge each resource's fragment into it.
532        // The merged spec is served at GET /openapi.json.
533        if !openapi_specs.is_empty() {
534            let mut merged = utoipa::openapi::OpenApiBuilder::new()
535                .info(
536                    utoipa::openapi::InfoBuilder::new()
537                        .title("Cinderblock JSON API")
538                        .version("0.1.0")
539                        .build(),
540                )
541                .build();
542
543            for spec in openapi_specs {
544                merged.merge(spec);
545            }
546
547            // # Swagger UI
548            //
549            // When the `swagger-ui` feature is enabled and the user hasn't
550            // disabled it, mount the Swagger UI at `/swagger-ui`. The
551            // SwaggerUi widget also serves the spec at `/openapi.json`.
552            #[cfg(feature = "swagger-ui")]
553            if self.swagger_ui {
554                router = router.merge(
555                    utoipa_swagger_ui::SwaggerUi::new("/swagger-ui").url("/openapi.json", merged),
556                );
557            }
558
559            #[cfg(not(feature = "swagger-ui"))]
560            let _ = self.swagger_ui;
561        }
562
563        router
564    }
565}
566
567/// Builds an `axum::Router` containing all auto-registered JSON API endpoints.
568///
569/// This is a convenience wrapper around `RouterConfig::new(ctx).build()`.
570/// Each resource that declared `cinderblock_json_api` in its `extensions` block will
571/// have its endpoints automatically included via `inventory` — no manual
572/// route construction is needed.
573pub fn router(ctx: impl Into<Arc<cinderblock_core::Context>>) -> axum::Router {
574    RouterConfig::new(ctx).build()
575}