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;
198
199use crate::data_layer::DataLayer;
200
201pub mod data_layer;
202
203pub type Result<T, E = Box<dyn std::error::Error + Send + Sync>> = std::result::Result<T, E>;
204
205/// Default number of items per page for paged read actions.
206///
207/// Individual actions can override this via `default_per_page` in the DSL.
208pub const DEFAULT_PER_PAGE: u32 = 100;
209
210// ---------------------------------------------------------------------------
211// # Pagination Types
212// ---------------------------------------------------------------------------
213
214/// Result type for paged read actions, containing the data page and metadata
215/// needed to navigate the full result set.
216#[derive(Debug, Clone, serde::Serialize)]
217pub struct PaginatedResult<T> {
218 pub data: Vec<T>,
219 pub meta: PaginationMeta,
220}
221
222/// Metadata describing the current page position within the full result set.
223#[derive(Debug, Clone, serde::Serialize)]
224pub struct PaginationMeta {
225 pub page: u32,
226 pub per_page: u32,
227 pub total: u64,
228 pub total_pages: u32,
229}
230
231/// Trait implemented on the **Arguments type** of paged read actions.
232///
233/// The generated `Paged` impl resolves `Option<u32>` fields into concrete
234/// page/per_page values using defaults and clamping from the DSL config.
235pub trait Paged {
236 fn page(&self) -> u32;
237 fn per_page(&self) -> u32;
238}
239
240#[derive(Debug, Default)]
241pub struct Context {
242 data_layers: HashMap<TypeId, Box<dyn Any + Sync + Send + 'static>>,
243}
244
245impl Context {
246 /// Generate a new context to be used by cinderblock applications.
247 ///
248 /// # Data layers
249 ///
250 /// This methods adds a [`data_layer::in_memory::InMemoryDataLayer`] by default.
251 pub fn new() -> Self {
252 let mut this = Self::default();
253 this.register_data_layer(data_layer::in_memory::InMemoryDataLayer::new());
254 this
255 }
256
257 /// Register a data layer instance so resources can look it up at runtime.
258 pub fn register_data_layer<DL: std::fmt::Debug + Send + Sync + 'static>(
259 &mut self,
260 data_layer: DL,
261 ) {
262 self.data_layers
263 .insert(data_layer.type_id(), Box::new(data_layer));
264 }
265
266 fn get_data_layer<DL: 'static>(&self) -> &DL {
267 self.data_layers
268 .get(&TypeId::of::<DL>())
269 .expect("Requested data layer was not registered")
270 .downcast_ref()
271 .expect("Could not downcast value stored in data layer")
272 }
273}
274
275/// Marker trait for a resource.
276pub trait Resource:
277 serde::Serialize + serde::de::DeserializeOwned + Send + Sync + Clone + 'static
278{
279 /// Primary key type of the resource. Usually the type of the id for the resource.
280 type PrimaryKey: std::fmt::Display + serde::de::DeserializeOwned + Send + Sync;
281
282 /// Data layer that the resource uses.
283 type DataLayer: DataLayer<Self>;
284
285 /// Name with namespace of the resource. Each part of the array is a segment in the name
286 /// (i.e. MyApp.Blog.Post).
287 const NAME: &'static [&'static str];
288
289 /// Wether the primary key of the resource is generated
290 const PRIMARY_KEY_GENERATED: bool;
291
292 /// Mathos that returns the primary key of the resource
293 fn primary_key(&self) -> &Self::PrimaryKey;
294}
295
296/// Marker trait indicating that a struct is a read action.
297///
298/// Non-paged actions set `Response = Vec<Output>`. Paged actions set
299/// `Response = PaginatedResult<Output>`. This lets the framework return
300/// the correct shape without runtime branching.
301pub trait ReadAction {
302 /// Resource returned when calling the action.
303 type Output: Resource;
304
305 /// Arguments used to get the resource. Could be used in filters.
306 type Arguments: Sync;
307
308 /// The full response type returned by this read action. Non-paged
309 /// actions use `Vec<Output>`, paged actions use `PaginatedResult<Output>`.
310 type Response: Send;
311}
312
313/// Trait indicating that a [`DataLayer`] can perform [`ReadAction`] `A`.
314pub trait PerformRead<A: ReadAction> {
315 /// Perform the read action on the provided data layer.
316 fn read(&self, args: &A::Arguments) -> impl Future<Output = Result<A::Response>>;
317}
318
319/// Trait placed on a [`Resource`] specifying how to create the resource using action `A`.
320pub trait Create<A>: Resource {
321 /// Input used to create the resource.
322 type Input;
323
324 /// Create an instance of the resource using [`Self::Input`].
325 fn from_create_input(input: Self::Input) -> Self;
326}
327
328/// Trait placed on a [`Resource`] specifying how to update a resource using action `A`.
329pub trait Update<A>: Resource {
330 /// Arguments to pass to [`Self::apply_update_input`].
331 type Input;
332
333 /// Update an instance of self using [`Self::Input`].
334 fn apply_update_input(&mut self, input: Self::Input);
335}
336
337/// Marker trait for destroy actions.
338pub trait Destroy<A>: Resource {}
339
340/// Create resource `R` using action `A`.
341pub async fn create<R, A>(input: R::Input, ctx: &Context) -> Result<R>
342where
343 R: Create<A>,
344{
345 let resource = R::from_create_input(input);
346 let dl = ctx.get_data_layer::<R::DataLayer>();
347 dl.create(resource).await
348}
349
350/// Update resource `R` using action `A`. First
351/// fetches an instance of `R` using the primary key.
352pub async fn update<R, A>(primary_key: &R::PrimaryKey, input: R::Input, ctx: &Context) -> Result<R>
353where
354 R: Update<A>,
355{
356 let dl = ctx.get_data_layer::<R::DataLayer>();
357 let mut resource = dl.read(primary_key).await?;
358 resource.apply_update_input(input);
359 dl.update(resource.clone()).await?;
360 Ok(resource)
361}
362
363/// Read resource `R` using action `A`.
364pub async fn read<R, A>(ctx: &Context, args: &A::Arguments) -> Result<A::Response>
365where
366 R: Resource,
367 A: ReadAction<Output = R>,
368 R::DataLayer: PerformRead<A>,
369{
370 let dl = ctx.get_data_layer::<R::DataLayer>();
371 PerformRead::<A>::read(dl, args).await
372}
373
374/// Destroy resource `R` using action `A`.
375pub async fn destroy<R, A>(primary_key: &R::PrimaryKey, ctx: &Context) -> Result<R>
376where
377 R: Destroy<A>,
378{
379 let dl = ctx.get_data_layer::<R::DataLayer>();
380 dl.destroy(primary_key).await
381}