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}