navree
Documentation

Settings

Typed settings with #[derive(Settings)], stored for the whole app, an organization, a workspace or a user, inherited field by field, with the source of every value.

On this page

Introduction

Featuresettings Most apps grow settings that more than one party gets a say in. The product team picks a default welcome text, an organization overrides it for all of its workspaces, one workspace overrides it again, and a user turns on a compact view for themselves. Your screens then need the value that applies and, to explain it, where it came from.

navree-settings handles that:

  • Settings are types. A struct with #[derive(Settings)] is one group of settings stored under one key. Its fields are the settings, Default gives the values when nothing is stored, and its garde rules check every write.
  • Values are inherited field by field down the chain global → organization → workspace → user. A workspace that sets only welcome_text still follows its organization for everything else.
  • Every resolved value knows its source: default, global, organization, workspace or user. The routes return it next to the values, so a settings screen can say "inherited from the organization" and offer to reset a field.
  • Writes are validated, audited, cached by tag and announced. They run in your unit of work, write a settings.updated audit entry, clear the cached values they affect and emit settings.changed after the commit.

You get the Settings service, the Setting<T> extractor, GET and PATCH /api/settings/{key}, the settings:* commands, a Helm screen and SettingsFake for tests.

Installation

The settings feature of the navree facade is part of full, so an app on the default features has it. With a hand-picked feature list, add it by name:

Cargo.toml

It brings navree::settings (the navree-settings crate, #[derive(Settings)] included) and puts SettingsModule, Setting and ModuleBuilderSettingsExt in the prelude.

Add SettingsModule to your app root:

src/app.rs

The module is global and contributes the migration set settings, which creates the settings table. It needs DatabaseModule (unless settings.store = "memory") and works with whatever else the app has:

WithYou get
CacheModuleResolved values cached by tag
AuditModuleA settings.updated entry for every change, in the change's transaction
EventsModulesettings.changed after every committed change; deleted workspaces and organizations lose their values
WorkspaceModuleWorkspace scopes inherit their organization's values; CurrentWorkspace and CurrentOrganization pick the scope of a request
AccessModuleThe settings.manage check on global writes through the routes

Configuration

Every key has a default:

config/app.toml

From the environment: APP_SETTINGS__STORE=memory, APP_SETTINGS__CACHE_TTL_SECS=60, APP_SETTINGS__ROUTES=false. SettingsModule::for_root(SettingsOptions { .. }) takes the same options in code.

Defining settings

A settings type is a struct whose fields are the settings. Derive Settings next to Serialize, Deserialize and garde's Validate, and give it a Default:

src/modules/portal/settings.rs
ArgumentDefaultMeaning
key = ".."the type name without Settings, in kebab case (TeamPortalSettings → team-portal)The stored key and the routes' path segment. Keep it stable: values are keyed by it.
scopes(..)global, organization, workspaceThe levels the settings may be stored at: global, organization, workspace, user
permission = ".."per level (see permissions)The permission a write needs at every level but the user's
read_permission = ".."noneThe permission a read through the routes needs

The doc comment becomes the description that settings:list, GET /api/settings and Helm show. A key holds lower-case letters, digits, -, _ and . (at most 100 characters, starting with a letter or a digit). A struct without named fields, generics, an unknown or repeated argument, an unknown level or an invalid key is a compile error. #[derive(Settings)] implements the SettingsGroup trait, whose constants (KEY, SCOPES, DESCRIPTION, PERMISSION, READ_PERMISSION) you can also write by hand.

Each top-level field (as serde names it) is stored, inherited and reported on its own. Any serde type works as a field: a Vec<String> or a nested struct is inherited as one value.

Register each type in the module that owns it:

src/modules/portal/mod.rs

In a register function, m.settings::<PortalSettings>() does the same (ModuleBuilderSettingsExt). Two types with one key fail the boot, naming both, and so does a type with an invalid key, no level, or a Default that does not serialize to a JSON object. Reading or writing a type no module registered fails with SettingsError::Unregistered, whose message names the missing for_feature import.

Scopes and inheritance

A Scope names who settings are resolved for and where a write goes:

Rust

Methods that take a scope also accept a &Workspace, a &CurrentWorkspace, an &Organization, a &CurrentOrganization or a &Principal (its user).

A scope's target is its most specific level: writes go there. Resolution reads the chain from the target up, one field at a time:

  1. User. The user's own value, when the scope names a user.
  2. Workspace. The workspace's value.
  3. Organization. The value of the organization the workspace belongs to, or the one the scope names.
  4. Global. The value set for the whole app.
  5. Default. The type's Default.

The first level that stores the field wins and becomes its source. Levels a type does not list in scopes(..) are skipped: NotificationSettings with scopes(global, workspace) resolves a user in a workspace to the workspace's values, and refuses a write at the organization level.

For a workspace scope whose organization you did not name, the module looks it up with WorkspaceModule and caches the answer; attaching or detaching the workspace clears it. Without WorkspaceModule, a workspace has no organization.

Reading settings

In handlers: Setting<T>

Setting<T> resolves T for the request: the signed-in user (when T has the user level), in the request's workspace and that workspace's organization (when T has either level and the request names a workspace), over the global values. It derefs to T:

src/modules/portal/controller.rs

A request that names no workspace resolves without one. A request that names a workspace the user may not enter is refused as CurrentWorkspace refuses it (404 or 403). A guest gets the global values. The extractor checks no permission: it reads for your handler's logic. portal.source("welcome_text"), portal.scope() and portal.resolved() say where the values came from and for whom.

Anywhere: the Settings service

Inject Arc<Settings> and pass a scope:

src/modules/portal/service.rs

resolve returns a Resolved<T>: the value (it derefs to it), sources (each field's Source), overridden (the fields stored at the target's own level, sorted) and the scope it was resolved for, with its organization filled in. get returns the value alone. Resolved<T> serializes as {data, sources, overridden}, so a handler can return it as JSON.

A stored value the type no longer accepts (the field's type changed since) is skipped with a warning in the log, so the level below shows through; a stored field the type no longer has is ignored.

Changing settings

Update

update sets fields at the level of the scope's target, in your unit of work, and returns the result. The patch is anything that serializes to a JSON object:

Rust

The values after the patch, inherited ones included, are checked with T's garde rules before anything is stored, so a rule across fields sees what will actually apply. A failure is SettingsError::Invalid with the messages by field; returned from a handler it is a 400:

400 Bad Request

A field the type does not have is SettingsError::UnknownFields (a 400 naming it), a level the type does not allow SettingsError::ScopeNotAllowed (a 400), and a patch that is not an object SettingsError::InvalidPatch. A field set to the value it already inherits is still stored: it no longer follows the level above. A patch that changes nothing stored writes nothing.

Inherit and reset

inherit removes one field from the target's level, so it follows the level above again; reset removes all of them:

Rust

Inheriting a field that already inherits changes nothing. stored::<T>(scope) returns the fields stored at a level, without inheritance.

Units of work, audit and events

Writes take the unit of work they belong to: with the database store they join its transaction and commit or roll back with it, and reads inside it see its uncommitted writes. Each change:

  • writes a settings.updated audit entry in the same transaction (with AuditModule): subject Settings and the key, the old and new values of the changed fields, and scope_type, scope_id and fields in its metadata, with the unit of work's actor;
  • clears the cached values it affects, at once and again after the commit;
  • emits SettingsChanged (settings.changed) through the event bus once the unit of work commits, never for a rolled-back change.
Rust

SettingsChanged carries key, scope_type, scope_id (None at the global level), fields, old and new (the changed fields as stored before and after; a field missing from new inherits again) and actor_id.

When a workspace or an organization is deleted, the module deletes the values stored for it (with EventsModule, from WorkspaceDeleted and OrganizationDeleted). Settings::forget_scope(target) does the same by hand.

Over HTTP

With settings.routes = true (the default) the module mounts, for signed-in users:

RouteAnswers
GET /api/settings{"data": [{key, description, scopes, fields}]}: every registered type
GET /api/settings/{key}?scope={data, sources, overridden} at the level
PATCH /api/settings/{key}?scope= {field: value, ..}Sets the fields at the level; the result
DELETE /api/settings/{key}?scope=Every field at the level inherits again; the result
DELETE /api/settings/{key}/{field}?scope=One field inherits again; the result

scope picks the level: global, organization (the request's organization, X-Organization), workspace (the request's workspace, X-Workspace) or user (the signed-in user, over the request's workspace when it names one). Without it, the most specific level the type allows.

HTTP
JSON

A settings screen shows data, marks each field with its source, and offers "reset" on the fields in overridden. An unknown key is a 404, an unknown level or one the type does not allow a 400. The routes are in the OpenAPI document under the Settings tag.

Permissions

Reads need what the level needs: any signed-in user reads the global values, a member reads the organization's or the workspace's, and users read their own. A type with read_permission also needs that permission at the level.

Writes need the type's permission, or by default:

LevelPermissionChecked
globalsettings.manageApp-wide, with navree-access
organizationorganization.updateIn the request's organization (org admins and owners)
workspaceworkspace.updateIn the request's workspace (admins and owners)
usernoneUsers change their own values

Managing settings

From the command line

CommandWhat it does
settings:listEvery registered type with its levels, fields and description
settings:show <key> [--workspace <id-or-slug>] [--organization <id-or-slug>] [--user <id>]The resolved values with the source of each
settings:set <key> <field>=<value>... [scope options]Sets fields at the level; without an option, globally
settings:inherit <key> <field>... | --all [scope options]Makes fields inherit again
Shell
Text

A value is read as JSON when it parses (true, 5, "5", ["a","b"]) and as text otherwise. The options combine: --user <id> --workspace acme is that user's preferences inside Acme. Slugs need WorkspaceModule; ids always work. settings:set and settings:inherit validate like the service and run in a unit of work of their own.

In Helm

With the helm and settings features on (both are in full), Helm gets a Settings entry under System. It lists the types the signed-in user manages: those whose permission they hold app-wide, else those settings.manage covers. Each type opens a form for a level, global or an organization, workspace or user named by its id or slug, showing every field with where its value comes from. Saving stores only the fields you changed, so the others keep inheriting; a field set at the level has an Inherit button. Writes go through Settings in the request's unit of work, so they are validated, audited and announced like any other.

Caching

With CacheModule in the app, resolved values are cached for settings.cache_ttl_secs (300 seconds), as are the organizations of workspaces. Each entry is tagged settings:<key> and settings:<key>:<level>:<id> for every level of its chain, so a write clears exactly what it affects: a workspace write clears that workspace's entries and its users', a global write every entry of the key. Reads inside a unit of work whose transaction is open read through it and are not cached. Without CacheModule, or with cache_ttl_secs = 0, every read goes to the store.

With several app instances, a change reaches the others as their cache store allows: within cache_ttl_secs with the per-process memory store, at once with a shared Redis store.

Storage

Values live in one table:

ColumnHolds
keyThe settings key (portal)
scope_typeglobal, organization, workspace or user
scope_idThe organization's, workspace's or user's id; the nil UUID for the global level
valueThe fields set at that level, as a JSON object
overridden_fieldsTheir names, sorted, as a JSON array
updated_at, updated_byWhen and by whom they last changed

The primary key is (key, scope_type, scope_id), with an index on (scope_type, scope_id). A row holds only the fields set at its level, and disappears with its last field. Scope ids are not foreign keys, so the table works with any user, workspace and organization store. It works on PostgreSQL, MySQL and SQLite.

Storage sits behind the SettingsStore trait: DatabaseSettingsStore (the table) and MemorySettingsStore (settings.store = "memory", lost on restart and not transactional).

Testing

In an app test, use the memory store, or the database store on a temporary database, and write the values each test needs through Settings:

tests/portal.rs

For unit tests, SettingsFake gives a Settings with the real resolution and validation on an in-memory store, with no registration and no database:

src/modules/portal/service.rs

set stores values as test setup: validated like a write, but not recorded as a change. attach(workspace, organization) tells the fake which organization a workspace belongs to.

The fake records every change made through its Settings. Install it in a test app to check what a route or a job wrote:

Rust

changes() lists every change in order, changes_of::<T>(scope) those of one type at one level, and clear() forgets them. Call register::<T>() up front when the code under test reads by key, as the routes and commands do; typed calls register their type on first use.

Reference

KeyDefaultMeaning
settings.storedatabasedatabase or memory
settings.cache_ttl_secs300Cache lifetime of resolved values; 0 turns the cache off
settings.routestrueMounts /api/settings
Settings method
resolve::<T>(scope)Resolved<T>: the values with their sources
get::<T>(scope)The values alone
update::<T>(&uow, scope, patch)Sets fields at the target's level, validated
inherit::<T>(&uow, scope, field)One field follows the level above again
reset::<T>(&uow, scope)Every field at the level inherits again
stored::<T>(scope)The fields stored at the target's level
resolve_key, update_key, inherit_key, reset_keyThe same by key, as JSON (ResolvedSettings)
registry(), rows(key)The registered types; every stored row
complete(scope)The scope with its workspace's organization filled in
forget_scope(target)Deletes every value stored at a level, without audit or events
SettingsErrorResponse
UnknownKey404
Invalid, UnknownFields, InvalidPatch400 with errors by field
ScopeNotAllowed400
Unregistered, Definition, Database, Corrupt, Other500 (logged)