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}