Entities and State
Entities are the core data structures in Stately. They represent the things your application manages - configurations, resources, settings...anything really. State is the container that holds collections of entities and provides operations for working with them.
Since proc-macros tend to generate a lot of "magic", it can sometimes be difficult to "see" what's being provided. Stately generates a simple demo page that can be found in the Stately rust docs, a module demonstrating the output derived from the doc_expand example.
Defining Entities
An entity is a Rust struct with the #[stately::entity] attribute:
use serde::{Deserialize, Serialize};
#[stately::entity]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Pipeline {
pub name: String,
pub description: Option<String>,
pub enabled: bool,
}
Required Traits
Entities must derive:
Clone - For state management operations
Serialize and Deserialize - For API serialization
The #[stately::entity] macro implements the HasName trait, which provides name-based lookups. This macro is not strictly necessary, but that trait's implementation is.
The name Field
By default, entities must have a name: String field. This field is used for:
- Display in lists and summaries
- Lookup by name (in addition to ID)
- Human-readable identification
You can customize which field provides the name:
#[stately::entity(name_field = "title")]
pub struct Document {
pub title: String,
pub content: String,
}
Or use a method:
#[stately::entity(name_method = "display_name")]
pub struct Config {
pub key: String,
pub value: String,
}
impl Config {
fn display_name(&self) -> &str {
&self.key
}
}
Adding Descriptions
Entities can have descriptions for richer summaries:
#[stately::entity(description_field = "summary")]
pub struct Task {
pub name: String,
pub summary: Option<String>,
}
Or with a static description:
#[stately::entity(description = "A data processing pipeline")]
pub struct Pipeline {
pub name: String,
}
Defining State
State is a container struct that holds entity collections. Use the #[stately::state] macro:
#[stately::state(openapi)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AppState {
pipelines: Pipeline,
sources: Source,
sinks: Sink,
}
What Gets Generated
The #[stately::state] macro generates:
1. StateEntry Enum
A discriminator for entity types:
pub enum StateEntry {
Pipeline,
Source,
Sink,
}
impl AsRef<str> for StateEntry { ... }
impl FromStr for StateEntry { ... }
2. Entity Enum
A type-erased wrapper for any entity:
pub enum Entity {
Pipeline(Pipeline),
Source(Source),
Sink(Sink),
}
3. Collection Fields
Each field becomes a typed collection:
pub struct AppState {
pub pipelines: Collection<Pipeline>,
pub sources: Collection<Source>,
pub sinks: Collection<Sink>,
}
4. State Methods
Operations for working with entities:
impl AppState {
pub fn new() -> Self;
pub fn get_entity(&self, id: &str, entry: StateEntry) -> Option<(EntityId, Entity)>;
pub fn list_entities(&self, entry: Option<StateEntry>) -> HashMap<StateEntry, Vec<Summary>>;
pub fn search_entities(&self, needle: &str) -> HashMap<StateEntry, Vec<Summary>>;
pub fn remove_entity(&mut self, id: &str, entry: StateEntry) -> Result<()>;
}
5. StateEntity Implementations
Each entity type gets connected to its state entry:
impl StateEntity for Pipeline {
type Entry = StateEntry;
const STATE_ENTRY: StateEntry = StateEntry::Pipeline;
}
The openapi Flag
Adding openapi to the state macro enables OpenAPI schema generation:
#[stately::state(openapi)]
pub struct AppState { ... }
This generates utoipa schema implementations for all types, enabling automatic API documentation.
Collections
Collections store multiple entities of the same type. They're backed by a HashMap<EntityId, T>.
Basic Operations
let mut state = AppState::new();
// Create
let id = state.pipelines.create(Pipeline {
name: "my-pipeline".into(),
description: None,
enabled: true,
});
// Read by ID
if let Some((id, pipeline)) = state.pipelines.get_entity(&id.to_string()) {
println!("Found: {}", pipeline.name);
}
// Read by name
if let Some((id, pipeline)) = state.pipelines.get_entity("my-pipeline") {
println!("Found by name: {}", pipeline.name);
}
// Update
state.pipelines.update(&id, Pipeline {
name: "my-pipeline".into(),
description: Some("Updated".into()),
enabled: false,
})?;
// Delete
let removed = state.pipelines.remove(&id)?;
// List all
for (id, pipeline) in state.pipelines.get_entities() {
println!("{}: {}", id, pipeline.name);
}
// Search
let results = state.pipelines.search_entities("pipe");
EntityId
Entity IDs are UUID v7 values, which are:
- Globally unique
- Time-sortable (newer IDs sort after older ones)
- URL-safe when serialized as strings
use stately::EntityId;
// Generate a new ID
let id = EntityId::new();
// Parse from string
let id: EntityId = "01234567-89ab-cdef-0123-456789abcdef".parse()?;
// Special singleton ID (nil UUID)
let singleton_id = EntityId::singleton();
Singletons
Singletons are single-instance containers for settings-like entities:
#[stately::state(openapi)]
pub struct AppState {
pipelines: Pipeline,
#[singleton]
settings: Settings,
}
Singleton Operations
// Get the singleton
let settings = state.settings.get();
// Modify the singleton
state.settings.set(Settings {
max_connections: 100,
timeout_seconds: 30,
});
// Get mutable reference
let settings = state.settings.get_mut();
settings.max_connections = 200;
Singletons differ from collections:
- Always exactly one instance
- No create/delete operations
- Uses a special nil UUID as its ID
- Ideal for application-wide settings
Custom Collection Types
You can use custom collection types instead of the default:
#[stately::state(openapi)]
pub struct AppState {
#[collection(MyCustomCollection)]
items: Item,
}
Your custom type must implement the StateCollection trait:
pub trait StateCollection {
type Entity: StateEntity;
fn create(&mut self, entity: Self::Entity) -> EntityId;
fn update(&mut self, id: &EntityId, entity: Self::Entity) -> Result<()>;
fn remove(&mut self, id: &EntityId) -> Result<Self::Entity>;
fn get_entity(&self, id: &str) -> Option<(&EntityId, &Self::Entity)>;
fn get_entities(&self) -> Vec<(&EntityId, &Self::Entity)>;
fn search_entities(&self, needle: &str) -> Vec<(&EntityId, &Self::Entity)>;
fn list(&self) -> Vec<Summary>;
fn is_empty(&self) -> bool;
}
Foreign Entity Types
Sometimes you need to store types you don't control (from other crates). Use the foreign attribute:
#[stately::state(openapi)]
pub struct AppState {
#[collection(foreign, variant = "JsonConfig")]
configs: serde_json::Value,
}
This generates a ForeignEntity trait that you implement:
impl ForeignEntity for serde_json::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())
}
}
Summary Type
The Summary type provides a lightweight representation for lists:
pub struct Summary {
pub id: EntityId,
pub name: String,
pub description: Option<String>,
}
Use summaries when you need to display entities without loading full data:
// Get summaries for the pipeline entity
let summaries = state.pipelines.list();
for summary in summaries {
println!("{}: {} - {:?}", summary.id, summary.name, summary.description);
}
All summaries for all entities can be retrieved using the list_entities method on the state struct:
// Get summaries for all entities
let summaries = state.list_entities();
for (entity_type, entity_summaries) in summaries {
println!("Entity Summaries for type {}:", entity_type);
for summary in entity_summaries {
println!(" {}: {} - {:?}", summary.id, summary.name, summary.description);
}
}