# 🏰 Stately
[](https://crates.io/crates/stately)
[](https://docs.rs/stately)
[](https://github.com/GeorgeLeePatterson/stately/tree/main/LICENSE)
[](https://codecov.io/gh/GeorgeLeePatterson/stately)
> Type-safe state management with entity relationships and CRUD operations
## Overview
Stately provides a framework for managing application configuration and state with built-in support for:
- 🔗 **Entity Relationships** - Reference entities inline or by ID
- 📝 **CRUD Operations** - Create, read, update, delete for all entity types
- 🔄 **Serialization** - Full serde support
- 📚 **OpenAPI Schemas** - Automatic schema generation with `utoipa`
- 🆔 **Time-Sortable IDs** - UUID v7 for naturally ordered identifiers
- 🚀 **Web APIs** - Optional Axum integration with generated handlers (more frameworks coming soon)
- 🔍 **Search & Query** - Built-in entity search across collections
- 🌍 **Foreign Types** - Use types from external crates in your state
Stately does not provide the configuration and structures that comprise the state. Instead it provides an ultra-thin container management strategy that provides seamless integration with [@statelyjs/stately](https://www.npmjs.com/package/@statelyjs/stately).
## Install
Add to your `Cargo.toml`:
```toml
[dependencies]
stately = "0.4.1"
```
With Axum API generation:
```toml
[dependencies]
stately = { version = "0.4.1", features = ["axum"] }
```
## Quick Start
### Define Entities
Use the `#[stately::entity]` macro to define your domain entities:
```rust
use stately::prelude::*;
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct Pipeline {
pub name: String,
pub source: Link<SourceConfig>,
}
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct SourceConfig {
pub name: String,
pub url: String,
}
```
### Define State
Use the `#[stately::state]` macro to create your application state:
```rust
#[stately::state]
pub struct AppState {
pipelines: Pipeline,
sources: SourceConfig,
}
```
This generates:
- `StateEntry` enum for entity type discrimination
- `Entity` enum for type-erased entity access
- `ForeignEntity` trait for externally defined entities
- Wrappers for renamed entities and foreign entities
- Collections with full CRUD operations
- Search and query methods
### Collection Attributes
Use `#[collection(...)]` to customize how collections are generated:
```rust
// Can be a struct that implements `StateCollection`. This type alias is for simplicity.
type CustomStateCollectionImpl = Collection<SourceConfig>;
#[stately::state]
pub struct AppState {
#[collection] // Default, same as omitting
pipelines: Pipeline,
#[collection(CustomStateCollectionImpl)]
sources: SourceConfig,
// variant = "..." sets the name used in the StateEntry and Entity enums
// Useful when multiple collections use the same entity type
#[collection(CustomStateCollectionImpl, variant = "CachedSourceConfig")]
sources_cached: SourceConfig,
// foreign allows using types from external crates
#[collection(foreign)]
configs: serde_json::Value,
}
```
Without `variant`, the macro generates enum variant names from the entity type name. Use `variant` to:
- Avoid naming collisions when using the same entity type in multiple collections
- Control the names in generated `StateEntry` and `Entity` enums
- Improve API clarity (e.g., `StateEntry::CachedSourceConfig` vs `StateEntry::SourceConfig`)
### Use the State
```rust
let mut state = AppState::new();
// Create entities
let source_id = state.sources.create(SourceConfig {
name: "my-source".to_string(),
url: "http://example.com".to_string(),
});
// Reference entities
let pipeline = Pipeline {
name: "my-pipeline".to_string(),
source: Link::create_ref(source_id.to_string()),
};
let pipeline_id = state.pipelines.create(pipeline);
// Query
let (id, entity) = state.get_entity(&pipeline_id.to_string(), StateEntry::Pipeline).unwrap();
// List all
let summaries = state.list_entities(None);
// Search
let results = state.search_entities("my-pipeline");
// Update
state.pipelines.update(&pipeline_id.to_string(), updated_pipeline)?;
// Delete
state.pipelines.remove(&pipeline_id.to_string())?;
```
## 📖 Examples
```rust
use stately::prelude::*;
// Define your entities
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct Pipeline {
pub name: String,
pub source: Link<SourceConfig>,
pub sink: Link<SinkConfig>,
}
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct SourceConfig {
pub name: String,
pub url: String,
}
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct SinkConfig {
pub name: String,
pub destination: String,
}
// Define your application state
#[stately::state(openapi)]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct AppState {
pipelines: Pipeline,
sources: SourceConfig,
sinks: SinkConfig,
}
fn main() {
let mut state = AppState::new();
// Create entities
let source_id = state.sources.create(SourceConfig {
name: "my-source".to_string(),
url: "http://example.com/data".to_string(),
});
let sink_id = state.sinks.create(SinkConfig {
name: "my-sink".to_string(),
destination: "s3://my-bucket/output".to_string(),
});
// Create a pipeline referencing the source and sink
let mut pipeline = Pipeline {
name: "my-pipeline".to_string(),
source: Link::create_ref(source_id.to_string()),
sink: Link::create_ref(sink_id.to_string()),
};
let pipeline_id = state.pipelines.create(pipeline.clone());
// Query entities
if let Some((id, entity)) = state.get_entity(&pipeline_id.to_string(), StateEntry::Pipeline) {
println!("Found pipeline: {:?}", entity);
}
// List all entities
let summaries = state.list_entities(None);
for (entry, entities) in summaries {
println!("{}: {} entities", entry.as_ref(), entities.len());
}
// Update
pipeline.name = "my-pipeline-updated".to_string();
state.pipelines.update(&pipeline_id.to_string(), pipeline)?;
// Delete
state.pipelines.remove(&pipeline_id.to_string())?;
// Search across collections
let results = state.search_entities("pipeline");
}
```
See the [examples](https://github.com/GeorgeLeePatterson/stately/tree/main/crates/stately/examples/) directory:
- [`basic.rs`](https://github.com/GeorgeLeePatterson/stately/tree/main/crates/stately/examples/basic.rs) - Core CRUD operations and entity relationships
- [`axum_api.rs`](https://github.com/GeorgeLeePatterson/stately/tree/main/crates/stately/examples/axum_api.rs) - Web API generation with Axum
- [`doc_expand.rs`](https://github.com/GeorgeLeePatterson/stately/tree/main/crates/stately/examples/doc_expand.rs) - Example used to generate documentation
Run examples:
```bash
cargo run --example basic
cargo run --example axum_api --features axum
```
## Entity Relationships with `Link<T>`
The `Link<T>` type allows flexible entity references:
```rust
// Reference by ID
let link = Link::create_ref("source-id-123");
// Inline embedding
let link = Link::inline(SourceConfig {
name: "inline-source".to_string(),
url: "http://example.com".to_string(),
});
// Access
match &pipeline.source {
Link::Ref(id) => println!("References source: {}", id),
Link::Inline(source) => println!("Inline source: {}", source.name),
}
```
## Singleton Entities
For configuration that should have exactly one instance:
```rust
#[stately::entity]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct Settings {
pub max_connections: usize,
}
#[stately::state]
pub struct AppState {
#[singleton]
settings: Settings,
}
```
## Foreign Type Support
Stately allows you to use types from external crates (foreign types) in your state by using the `#[collection(foreign)]` attribute. This is useful for managing third-party types like configuration formats, API responses, or other external data structures.
When you mark a collection as `foreign`, the `#[stately::state]` macro generates a `ForeignEntity` trait in your crate that you can implement on external types:
```rust
use serde_json::Value;
#[stately::state]
pub struct AppState {
#[collection(foreign, variant = "JsonConfig")]
json_configs: Value,
}
// The macro generates this trait in your crate:
// pub trait ForeignEntity: Clone + Serialize + for<'de> Deserialize<'de> {
// fn name(&self) -> &str;
// fn description(&self) -> Option<&str> { None }
// fn summary(&self, id: EntityId) -> Summary { ... }
// }
// Now you can implement it on the external type
impl ForeignEntity for Value {
fn name(&self) -> &str {
self.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unnamed")
}
fn description(&self) -> Option<&str> {
self.get("description")
.and_then(|v| v.as_str())
}
}
// Use like any other entity
let mut state = AppState::new();
let config = serde_json::json!({
"name": "my-config",
"description": "A JSON configuration"
});
let id = state.json_configs.create(config);
```
Because `ForeignEntity` is generated in your crate (not in stately), you can implement it on types from external crates without violating Rust's orphan rules. The macro creates wrapper types in the `Entity` enum that delegate to your `ForeignEntity` implementation, ensuring full compatibility with state operations.
## 🌐 Web API Generation (Axum currently)
Generate a complete REST API with OpenAPI documentation:
```rust
#[stately::state(openapi)]
pub struct State {
pipelines: Pipeline,
}
#[stately::axum_api(State, openapi(components = [link_aliases::PipelineLink]))]
pub struct ApiState {}
// Now in scope:
// - Trait implementations
// - All endpoints, response, request, and query types and ResponseEvent enum
// - `link_aliases` module
// - `impl AppState` with all state methods
#[tokio::main]
async fn main() {
let app_state = ApiState::new(State::new());
let app = axum::Router::new()
.nest("/api/v1/entity", ApiState::router(app_state.clone()))
.with_state(app_state);
// Generated routes:
// PUT /api/v1/entity - Create entity
// GET /api/v1/entity - List all entities by StateEntry
// GET /api/v1/entity/list - List all entities as summaries
// GET /api/v1/entity/list/{type} - List all entities filtered by type as summaries
// GET /api/v1/entity/{id}?type=<type> - Get entity by ID
// POST /api/v1/entity/{id} - Update entity
// PATCH /api/v1/entity/{id} - Patch entity
// DELETE /api/v1/entity/{entry}/{id} - Delete entity
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
```
### Event Middleware for Persistence
The `axum_api` macro generates a `ResponseEvent` enum and `event_middleware()` method for integrating with databases:
```rust
use tokio::sync::mpsc;
// Your event enum that wraps ResponseEvent
pub enum ApiEvent {
StateEvent(ResponseEvent),
}
// Implement From<ResponseEvent> for ApiEvent
impl From<ResponseEvent> for ApiEvent {
fn from(event: ResponseEvent) -> Self {
ApiEvent::StateEvent(event)
}
}
let (event_tx, mut event_rx) = mpsc::channel(100);
let app = axum::Router::new()
.nest("/api/v1/entity", ApiState::router(app_state.clone()))
.layer(axum::middleware::from_fn(
ApiState::event_middleware::<ApiEvent>(event_tx)
))
.with_state(app_state);
// Background task to handle events
tokio::spawn(async move {
while let Some(ApiEvent::StateEvent(event)) = event_rx.recv().await {
match event {
// Persist to database after state update
ResponseEvent::Created { id, entity } => db.insert(id, entity).await,
ResponseEvent::Updated { id, entity } => db.update(id, entity).await,
ResponseEvent::Deleted { id, entry } => db.delete(id, entry).await,
}
}
});
```
The `axum_api` macro generates:
- ✅ Complete REST API handlers as methods on your struct
- ✅ OpenAPI 3.0 documentation (with `openapi` parameter)
- ✅ Type-safe request/response types
- ✅ `router()` method and `ApiState::openapi()` for docs
- ✅ `ResponseEvent` enum and `event_middleware()` for event-driven persistence
### Macro Parameters
- **`#[stately::state(openapi)]`** - Enables OpenAPI schema generation for entities
- **`#[stately::axum_api(State, openapi(components = [...]))]`**
- First parameter: The state type name
- `openapi`: Enable OpenAPI documentation generation
- `openapi(components = [...])`: Additional types to include in OpenAPI schemas (e.g., Link types)
### Generated API Routes
The `axum_api` macro generates these endpoints:
- `PUT /` - Create a new entity
- `GET /` - Get all entities
- `GET /list` - List all entities by summary
- `GET /list/{type}` - List all entities filtered by type by summary
- `GET /{id}?type=<type>` - Get entity by ID and type
- `POST /{id}` - Update an existing entity
- `PATCH /{id}` - Patch an existing entity
- `DELETE /{entry}/{id}` - Delete an entity
### OpenAPI Documentation
Access the generated OpenAPI spec:
```rust
use utoipa::OpenApi;
#[stately::state(openapi)]
pub struct State {
pipelines: Pipeline,
}
#[stately::axum_api(State, openapi(components = [link_aliases::PipelineLink]))]
pub struct ApiState {}
let openapi = ApiState::openapi();
let json = openapi.to_json().unwrap();
```
## Feature Flags
| `openapi` | Enable OpenAPI schema generation via `utoipa` | ✅ Yes |
| `axum` | Enable Axum web framework integration | ❌ No |
## Entity Attributes
The `#[stately::entity]` macro implements the `HasName` trait and supports these attributes:
```rust
// Default: uses the "name" field
#[stately::entity]
// Use a different field for the entity name
#[stately::entity(name_field = "title")]
// Use a method to get the name
#[stately::entity(name_method = "get_identifier")]
```
## API Reference
### Core Types
- **`Collection<T>`** - A collection of entities with CRUD operations
- **`Singleton<T>`** - A single entity instance
- **`Link<T>`** - Reference to another entity (by ID or inline)
- **`EntityId`** - UUID v7 identifier for entities
- **`Summary`** - Lightweight entity summary for listings
### Traits
- **`HasName`** - Trait for providing entity names (implemented by `#[stately::entity]`)
- **`StateEntity`** - Trait for all entity types (implemented by `#[stately::state]`)
- **`StateCollection`** - Trait for entity collections (implemented by `#[stately::state]`)
### Macros
- **`#[stately::entity]`** - Implements the `HasName` trait for an entity type
- **`#[stately::state]`** - Define application state with entity collections
## Architecture
Stately uses procedural macros to generate boilerplate at compile time:
1. **`#[stately::entity]`** implements the `HasName` trait
2. **`#[stately::state]`** generates:
- `StateEntry` enum for entity type discrimination
- `Entity` enum for type-erased entity wrapper
- `ForeignEntity` for external entities
- Collection fields with type-safe accessors
- CRUD operation methods
- `link_aliases` module with `Link<T>` type aliases
3. **`#[stately::axum_api(State, ...)]`** generates (optional):
- REST API handler methods on your struct
- `router()` method for Axum integration
- OpenAPI documentation (when `openapi` parameter is used)
- `ResponseEvent` enum for CRUD operations
- `event_middleware()` method for event streaming
All generated code is type-safe and benefits from Rust's compile-time guarantees.
### Generated Code
**`link_aliases` Module** (from `#[stately::state]`):
```rust
pub mod link_aliases {
pub type PipelineLink = ::stately::Link<Pipeline>;
pub type SourceLink = ::stately::Link<Source>;
// ... one type alias for each entity in your state
}
```
**`ResponseEvent` Enum** (from `#[stately::axum_api]`):
```rust
pub enum ResponseEvent {
Created { id: EntityId, entity: Entity },
Updated { id: EntityId, entity: Entity },
Deleted { id: EntityId, entry: StateEntry },
}
```
These enable type-safe event-driven architectures for persistence, logging, and system integration.
## License
Licensed under the Apache License, Version 2.0. See [LICENSE](https://github.com/GeorgeLeePatterson/stately/tree/main/LICENSE) for details.
## Links
- [Documentation](https://docs.rs/stately)
- [Crates.io](https://crates.io/crates/stately)
- [Repository](https://github.com/georgeleepatterson/stately)
- [npm](https://www.npmjs.com/package/@statelyjs/stately)