Skip to main content

cinderblock_core/
lib.rs

1//! Core crate for the cinderblock framework — a declarative, resource-oriented
2//! application framework for Rust.
3//!
4//! This crate provides the [`resource!`] macro, the [`Resource`] trait, CRUD
5//! operation traits ([`Create`], [`Update`], [`Destroy`], [`ReadAction`]), the
6//! runtime [`Context`], and a built-in [`InMemoryDataLayer`](data_layer::in_memory::InMemoryDataLayer)
7//! for prototyping.
8//!
9//! # The `resource!` macro
10//!
11//! The [`resource!`] macro is the primary entry point for defining domain
12//! models. It accepts a declarative DSL and generates:
13//!
14//! - A **struct** with the declared attributes (derives `Serialize`,
15//!   `Deserialize`, `Clone`, `Debug`).
16//! - A [`Resource`] trait impl with primary key metadata and the configured
17//!   data layer.
18//! - For each action, a **marker struct** and the corresponding CRUD trait
19//!   impl. Create and update actions also generate an **input struct**.
20//! - Extension dispatch — each declared extension receives the full DSL
21//!   tokens so it can generate its own code (e.g. route handlers, SQL
22//!   queries).
23//!
24//! ## DSL reference
25//!
26//! ```rust,ignore
27//! use cinderblock_core::resource;
28//!
29//! resource! {
30//!     // A dotted name identifying the resource. The last segment becomes the
31//!     // struct name; all segments are available at runtime via `Resource::NAME`.
32//!     name = Helpdesk.Support.Ticket;
33//!
34//!     // Optional: override the data layer. Defaults to `InMemoryDataLayer`.
35//!     // data_layer = cinderblock_sqlx::sqlite::SqliteDataLayer;
36//!
37//!     attributes {
38//!         // Each attribute is `name Type` followed by either `;` or an options block.
39//!         ticket_id Uuid {
40//!             primary_key true;   // Marks this as the primary key (default: false).
41//!             writable false;     // Excludes from create/update input structs (default: true).
42//!             generated true;     // Indicates the PK is auto-generated (default: false).
43//!             default || Uuid::new_v4();  // Closure producing a default value.
44//!         }
45//!
46//!         // Simple form — writable, not a primary key, no default.
47//!         subject String;
48//!         status TicketStatus;
49//!     }
50//!
51//!     actions {
52//!         // ── Read actions ──
53//!         //
54//!         // A read action returns `Vec<Resource>`. It can optionally declare
55//!         // arguments (typed query parameters) and filters.
56//!
57//!         // Minimal read — no filters, no arguments. Arguments type is `()`.
58//!         read all;
59//!
60//!         // Read with a compile-time literal filter.
61//!         read open_tickets {
62//!             filter { status == TicketStatus::Open };
63//!         };
64//!
65//!         // Read with a runtime argument bound to a filter.
66//!         // Generates a `ByStatusArguments` struct with a `status` field.
67//!         read by_status {
68//!             argument { status: TicketStatus };
69//!             filter { status == arg(status) };
70//!         };
71//!
72//!         // Optional arguments use `Option<T>`. When `None`, the filter is
73//!         // skipped entirely at runtime.
74//!         read search {
75//!             argument { status: Option<TicketStatus> };
76//!             filter { status == arg(status) };
77//!         };
78//!
79//!         // ── Create actions ──
80//!         //
81//!         // A create action generates an input struct from the resource's
82//!         // writable attributes and a `Create<A>` impl that builds a new
83//!         // resource instance.
84//!
85//!         // Accepts all writable attributes. Generates `OpenInput { subject, status }`.
86//!         create open;
87//!
88//!         // Restrict which fields the input struct includes.
89//!         // Generates `AssignInput { subject }`.
90//!         create assign {
91//!             accept [subject];
92//!         };
93//!
94//!         // ── Update actions ──
95//!         //
96//!         // An update action fetches the resource by primary key, applies
97//!         // changes, and persists the result. It generates an input struct
98//!         // and an `Update<A>` impl.
99//!
100//!         // Accepts all writable attributes.
101//!         update edit;
102//!
103//!         // Accept no fields from the caller, but apply a programmatic
104//!         // mutation via `change_ref`. Multiple `change_ref` blocks are
105//!         // applied in order.
106//!         update close {
107//!             accept [];
108//!             change_ref |ticket| {
109//!                 ticket.status = TicketStatus::Closed;
110//!             };
111//!         };
112//!
113//!         // ── Destroy actions ──
114//!         //
115//!         // A destroy action deletes the resource by primary key.
116//!         destroy remove;
117//!     }
118//!
119//!     // Optional: declare extensions. Each extension module receives the
120//!     // full resource DSL and its own configuration block, then generates
121//!     // additional code (e.g. route handlers, SQL queries).
122//!     extensions {
123//!         cinderblock_json_api {
124//!             route = { method = GET; path = "/"; action = all; };
125//!             route = { method = POST; path = "/"; action = open; };
126//!         };
127//!
128//!         cinderblock_sqlx {
129//!             table = "tickets";
130//!         };
131//!     }
132//! }
133//! ```
134//!
135//! ## Generated items
136//!
137//! For a resource named `Helpdesk.Support.Ticket` with actions `open`
138//! (create), `close` (update), `open_tickets` (read), and `remove` (destroy),
139//! the macro generates:
140//!
141//! | Generated item | Kind | Description |
142//! |---|---|---|
143//! | `Ticket` | struct | The resource struct with all declared attributes |
144//! | `Open` | struct (marker) | Create action marker |
145//! | `OpenInput` | struct | Input fields for the `open` create action |
146//! | `Close` | struct (marker) | Update action marker |
147//! | `CloseInput` | struct | Input fields for the `close` update action |
148//! | `OpenTickets` | struct (marker) | Read action marker |
149//! | `Remove` | struct (marker) | Destroy action marker |
150//!
151//! Action names are converted to `PascalCase` for the marker and input struct
152//! names (e.g. `open_tickets` becomes `OpenTickets`, and its input struct
153//! would be `OpenTicketsInput`).
154//!
155//! ## Using the generated types
156//!
157//! ```rust,ignore
158//! use cinderblock_core::Context;
159//!
160//! let ctx = Context::new();
161//!
162//! // Create
163//! let ticket = cinderblock_core::create::<Ticket, Open>(
164//!     OpenInput { subject: "Printer is broken".into(), status: TicketStatus::Open },
165//!     &ctx,
166//! ).await?;
167//!
168//! // Read (with arguments)
169//! let open = cinderblock_core::read::<Ticket, ByStatus>(
170//!     &ctx,
171//!     &ByStatusArguments { status: TicketStatus::Open },
172//! ).await?;
173//!
174//! // Read (no arguments — pass `&()`)
175//! let all_open = cinderblock_core::read::<Ticket, OpenTickets>(&ctx, &()).await?;
176//!
177//! // Update
178//! let closed = cinderblock_core::update::<Ticket, Close>(
179//!     &ticket.ticket_id,
180//!     CloseInput {},
181//!     &ctx,
182//! ).await?;
183//!
184//! // Destroy
185//! let removed = cinderblock_core::destroy::<Ticket, Remove>(
186//!     &ticket.ticket_id,
187//!     &ctx,
188//! ).await?;
189//! ```
190
191use std::{
192    any::{Any, TypeId},
193    collections::HashMap,
194};
195
196pub use cinderblock_core_macros::resource;
197pub use serde;
198pub use thiserror;
199
200use crate::data_layer::DataLayer;
201
202pub mod data_layer;
203
204// ---------------------------------------------------------------------------
205// # Error Types
206// ---------------------------------------------------------------------------
207
208/// Structured error carrying the resource name and an action-specific error
209/// variant. The type parameter `E` differs per CRUD action so that callers
210/// can match on only the variants relevant to the operation that failed.
211#[derive(Debug)]
212pub struct Error<E: std::error::Error> {
213    resource: &'static str,
214    data: E,
215}
216
217impl<E: std::error::Error> Error<E> {
218    pub fn new<R: Resource>(data: E) -> Self {
219        Self {
220            resource: R::RESOURCE_NAME,
221            data,
222        }
223    }
224
225    pub fn resource(&self) -> &'static str {
226        self.resource
227    }
228
229    pub fn data(&self) -> &E {
230        &self.data
231    }
232
233    pub fn into_data(self) -> E {
234        self.data
235    }
236}
237
238impl<E: std::error::Error> std::fmt::Display for Error<E> {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        write!(f, "{}: {}", self.resource, self.data)
241    }
242}
243
244impl<E: std::error::Error + 'static> std::error::Error for Error<E> {
245    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
246        self.data.source()
247    }
248}
249
250/// Error variants for create operations.
251#[derive(Debug, thiserror::Error)]
252pub enum CreateError {
253    /// The underlying data layer returned an error.
254    #[error("{0}")]
255    DataLayer(#[source] Box<dyn std::error::Error + Send + Sync>),
256}
257
258/// Error variants for single-resource read operations (by primary key).
259#[derive(Debug, thiserror::Error)]
260pub enum ReadError {
261    /// No resource found for the given primary key.
262    #[error("not found for primary key `{primary_key}`")]
263    NotFound { primary_key: String },
264
265    /// The underlying data layer returned an error.
266    #[error("{0}")]
267    DataLayer(#[source] Box<dyn std::error::Error + Send + Sync>),
268}
269
270/// Error variants for read-action (list) operations.
271#[derive(Debug, thiserror::Error)]
272pub enum ListError {
273    /// The underlying data layer returned an error.
274    #[error("{0}")]
275    DataLayer(#[source] Box<dyn std::error::Error + Send + Sync>),
276}
277
278/// Error variants for update operations.
279#[derive(Debug, thiserror::Error)]
280pub enum UpdateError {
281    /// No resource found for the given primary key.
282    #[error("not found for primary key `{primary_key}`")]
283    NotFound { primary_key: String },
284
285    /// The underlying data layer returned an error.
286    #[error("{0}")]
287    DataLayer(#[source] Box<dyn std::error::Error + Send + Sync>),
288}
289
290/// Error variants for destroy operations.
291#[derive(Debug, thiserror::Error)]
292pub enum DestroyError {
293    /// No resource found for the given primary key.
294    #[error("not found for primary key `{primary_key}`")]
295    NotFound { primary_key: String },
296
297    /// The underlying data layer returned an error.
298    #[error("{0}")]
299    DataLayer(#[source] Box<dyn std::error::Error + Send + Sync>),
300}
301
302/// Default number of items per page for paged read actions.
303///
304/// Individual actions can override this via `default_per_page` in the DSL.
305pub const DEFAULT_PER_PAGE: u32 = 100;
306
307// ---------------------------------------------------------------------------
308// # Pagination Types
309// ---------------------------------------------------------------------------
310
311/// Result type for paged read actions, containing the data page and metadata
312/// needed to navigate the full result set.
313#[derive(Debug, Clone, serde::Serialize)]
314pub struct PaginatedResult<T> {
315    pub data: Vec<T>,
316    pub meta: PaginationMeta,
317}
318
319/// Metadata describing the current page position within the full result set.
320#[derive(Debug, Clone, serde::Serialize)]
321pub struct PaginationMeta {
322    pub page: u32,
323    pub per_page: u32,
324    pub total: u64,
325    pub total_pages: u32,
326}
327
328/// Trait implemented on the **Arguments type** of paged read actions.
329///
330/// The generated `Paged` impl resolves `Option<u32>` fields into concrete
331/// page/per_page values using defaults and clamping from the DSL config.
332pub trait Paged {
333    fn page(&self) -> u32;
334    fn per_page(&self) -> u32;
335}
336
337#[derive(Debug, Default)]
338pub struct Context {
339    data_layers: HashMap<TypeId, Box<dyn Any + Sync + Send + 'static>>,
340}
341
342impl Context {
343    /// Generate a new context to be used by cinderblock applications.
344    ///
345    /// # Data layers
346    ///
347    /// This methods adds a [`data_layer::in_memory::InMemoryDataLayer`] by default.
348    pub fn new() -> Self {
349        let mut this = Self::default();
350        this.register_data_layer(data_layer::in_memory::InMemoryDataLayer::new());
351        this
352    }
353
354    /// Register a data layer instance so resources can look it up at runtime.
355    pub fn register_data_layer<DL: std::fmt::Debug + Send + Sync + 'static>(
356        &mut self,
357        data_layer: DL,
358    ) {
359        self.data_layers
360            .insert(data_layer.type_id(), Box::new(data_layer));
361    }
362
363    fn get_data_layer<DL: 'static>(&self) -> &DL {
364        self.data_layers
365            .get(&TypeId::of::<DL>())
366            .expect("Requested data layer was not registered")
367            .downcast_ref()
368            .expect("Could not downcast value stored in data layer")
369    }
370}
371
372/// Marker trait for a resource.
373pub trait Resource:
374    serde::Serialize + serde::de::DeserializeOwned + Send + Sync + Clone + 'static
375{
376    /// Primary key type of the resource. Usually the type of the id for the resource.
377    type PrimaryKey: std::fmt::Display + serde::de::DeserializeOwned + Send + Sync;
378
379    /// Data layer that the resource uses.
380    type DataLayer: DataLayer<Self>;
381
382    /// Name with namespace of the resource. Each part of the array is a segment in the name
383    /// (i.e. MyApp.Blog.Post).
384    const NAME: &'static [&'static str];
385
386    /// Dot-joined resource name (e.g. `"MyApp.Blog.Post"`).
387    ///
388    /// Generated by the `resource!` macro as a string literal — no runtime
389    /// allocation or `leak()` required.
390    const RESOURCE_NAME: &'static str;
391
392    /// Wether the primary key of the resource is generated
393    const PRIMARY_KEY_GENERATED: bool;
394
395    /// Mathos that returns the primary key of the resource
396    fn primary_key(&self) -> &Self::PrimaryKey;
397
398    /// Lifecycle hook called on every create action, after the resource is
399    /// built from input but before persistence. Override to mutate the
400    /// resource (e.g. set `created_at` timestamps).
401    fn before_create(&mut self) {}
402
403    /// Lifecycle hook called on every update action, after
404    /// `apply_update_input` but before persistence. Override to mutate the
405    /// resource (e.g. set `updated_at` timestamps).
406    fn before_update(&mut self) {}
407}
408
409/// Marker trait indicating that a struct is a read action.
410///
411/// Non-paged actions set `Response = Vec<Output>`. Paged actions set
412/// `Response = PaginatedResult<Output>`. This lets the framework return
413/// the correct shape without runtime branching.
414pub trait ReadAction {
415    /// Resource returned when calling the action.
416    type Output: Resource;
417
418    /// Arguments used to get the resource. Could be used in filters.
419    type Arguments: Sync;
420
421    /// The full response type returned by this read action. Non-paged
422    /// actions use `Vec<Output>`, paged actions use `PaginatedResult<Output>`.
423    type Response: Send;
424}
425
426/// Trait indicating that a [`DataLayer`] can perform [`ReadAction`] `A`.
427pub trait PerformRead<A: ReadAction> {
428    /// Perform the read action on the provided data layer.
429    fn read(&self, args: &A::Arguments) -> impl Future<Output = Result<A::Response, ListError>>;
430}
431
432/// Trait indicating that a [`DataLayer`] can perform a get (single-resource)
433/// [`ReadAction`] `A`, returning the resource or `ReadError::NotFound`.
434pub trait PerformReadOne<A: ReadAction> {
435    fn read_one(&self, args: &A::Arguments)
436    -> impl Future<Output = Result<A::Response, ReadError>>;
437}
438
439/// Trait placed on a [`Resource`] specifying how to create the resource using action `A`.
440pub trait Create<A>: Resource {
441    /// Input used to create the resource.
442    type Input;
443
444    /// Create an instance of the resource using [`Self::Input`].
445    fn from_create_input(input: Self::Input) -> Self;
446}
447
448/// Trait placed on a [`Resource`] specifying how to update a resource using action `A`.
449pub trait Update<A>: Resource {
450    /// Arguments to pass to [`Self::apply_update_input`].
451    type Input;
452
453    /// Update an instance of self using [`Self::Input`].
454    fn apply_update_input(&mut self, input: Self::Input);
455}
456
457/// Marker trait for destroy actions.
458pub trait Destroy<A>: Resource {}
459
460/// Create resource `R` using action `A`.
461pub async fn create<R, A>(input: R::Input, ctx: &Context) -> Result<R, Error<CreateError>>
462where
463    R: Create<A>,
464{
465    let mut resource = R::from_create_input(input);
466    resource.before_create();
467    let dl = ctx.get_data_layer::<R::DataLayer>();
468    dl.create(resource).await.map_err(|e| Error::new::<R>(e))
469}
470
471/// Update resource `R` using action `A`. First
472/// fetches an instance of `R` using the primary key.
473pub async fn update<R, A>(
474    primary_key: &R::PrimaryKey,
475    input: R::Input,
476    ctx: &Context,
477) -> Result<R, Error<UpdateError>>
478where
479    R: Update<A>,
480{
481    let dl = ctx.get_data_layer::<R::DataLayer>();
482    let mut resource = dl.read(primary_key).await.map_err(|e| {
483        Error::new::<R>(match e {
484            ReadError::NotFound { primary_key } => UpdateError::NotFound { primary_key },
485            ReadError::DataLayer(source) => UpdateError::DataLayer(source),
486        })
487    })?;
488    resource.apply_update_input(input);
489    resource.before_update();
490    dl.update(resource.clone())
491        .await
492        .map_err(|e| Error::new::<R>(e))?;
493    Ok(resource)
494}
495
496/// Read resource `R` using action `A`.
497pub async fn read<R, A>(ctx: &Context, args: &A::Arguments) -> Result<A::Response, Error<ListError>>
498where
499    R: Resource,
500    A: ReadAction<Output = R>,
501    R::DataLayer: PerformRead<A>,
502{
503    let dl = ctx.get_data_layer::<R::DataLayer>();
504    PerformRead::<A>::read(dl, args)
505        .await
506        .map_err(|e| Error::new::<R>(e))
507}
508
509/// Read a single resource `R` by primary key using get-action `A`.
510pub async fn read_one<R, A>(
511    ctx: &Context,
512    args: &A::Arguments,
513) -> Result<A::Response, Error<ReadError>>
514where
515    R: Resource,
516    A: ReadAction<Output = R>,
517    R::DataLayer: PerformReadOne<A>,
518{
519    let dl = ctx.get_data_layer::<R::DataLayer>();
520    PerformReadOne::<A>::read_one(dl, args)
521        .await
522        .map_err(|e| Error::new::<R>(e))
523}
524
525/// Destroy resource `R` using action `A`.
526pub async fn destroy<R, A>(
527    primary_key: &R::PrimaryKey,
528    ctx: &Context,
529) -> Result<R, Error<DestroyError>>
530where
531    R: Destroy<A>,
532{
533    let dl = ctx.get_data_layer::<R::DataLayer>();
534    dl.destroy(primary_key)
535        .await
536        .map_err(|e| Error::new::<R>(e))
537}