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