Skip to content

Add a self-describing settings descriptor endpoint - #10

Open
chrispmonkey wants to merge 4 commits into
PenumbraOS:masterfrom
chrispmonkey:pr-settings-schema
Open

Add a self-describing settings descriptor endpoint#10
chrispmonkey wants to merge 4 commits into
PenumbraOS:masterfrom
chrispmonkey:pr-settings-schema

Conversation

@chrispmonkey

@chrispmonkey chrispmonkey commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Adds GET /api/settings/schema — the device's settings reported as sections of
typed fields (string / text / secret / bool / enum, each with UI
metadata) so web and mobile clients render the settings form from the descriptor
instead of hardcoding every field. A new config field surfaces in every client
by declaring it here.

The descriptor is built from #[derive(Serialize)] structs — serde does the
encoding. Enum options derive from the config enum via schemars (a generic
enum_variants::<T>()), so a dropdown can't drift from the real variants.

Read-only: writes continue to go through the existing typed PUT /api/settings,
so there's one validated write path and no key/value re-encoding on the way in.

Comment thread server-rs/src/api/settings_schema.rs Outdated
@@ -0,0 +1,287 @@
//! Self-describing settings descriptor (`GET /api/settings/schema`).
//!
//! Reports the settings as sections of typed fields so web and mobile clients

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove most of this comment

Comment thread server-rs/src/api/settings_schema.rs Outdated
State(state): State<ApiState>,
Json(changes): Json<Map<String, Value>>,
) -> Response {
let body: super::UpdateSettingsRequest = match serde_json::from_value(nest_dotted(changes)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty weird. Why not construct the actual type rather than deserializing then reserializing it?

Comment thread server-rs/src/api/settings_schema.rs Outdated
json!({
"sections": [
section("server", "Server", vec![
text("server.display_name", "Display Name",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, though I really feel like there should be a nicer way to do it. At very least a Serde JSON serializer with annotations for titles

Comment thread server-rs/src/api/settings_schema.rs Outdated
Text,
}

fn section(key: &str, label: &str, fields: Vec<Value>) -> Value {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of this should really be Serde serializers

Comment thread server-rs/src/api/settings_schema.rs Outdated
/// so the list can't drift from the enum — a new variant surfaces automatically
/// (with its wire name until it's given a friendlier label in `provider_label`).
fn provider_options() -> Vec<(String, String)> {
let schema = serde_json::to_value(schemars::schema_for!(LlmProvider)).unwrap_or_default();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a implementation you will use often. Not just for LlmProvider

@chrispmonkey

Copy link
Copy Markdown
Contributor Author

Reworked per your notes — the descriptor is now #[derive(Serialize)] structs (serde does the encoding), options come from a generic enum_variants::<T>(), and I dropped the flat key/value write: the descriptor is read-only and writes go through the existing typed PUT /api/settings, so there's no nest-then-deserialize. api.rs is back to a two-line diff.

@chrispmonkey
chrispmonkey marked this pull request as ready for review August 6, 2026 22:32
Comment thread server-rs/src/api/settings_schema.rs Outdated
value: Option<Value>,
/// `secret` only: a value is set, without revealing it.
#[serde(skip_serializing_if = "Option::is_none")]
configured: Option<bool>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Rust. We can use enum embedded types to make this much nicer. You have a type (kind) discriminator, and only non-secrets have values and only secrets have isSet (don't use configured), etc.

Comment thread server-rs/src/api/settings_schema.rs Outdated
options: Vec<EnumOption>,
/// Show only while another field equals a value.
#[serde(rename = "visibleWhen", skip_serializing_if = "Option::is_none")]
visible_when: Option<BTreeMap<&'static str, &'static str>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems overly generic. This should probably be some sort of submenu system based on a parent enum:

Pick the enum variant
Variant 1
Variant 2
Variant 3

Inside of Variant 2, there is a sub field system that only displays when Variant 2 is selcted

Comment thread server-rs/src/api/settings_schema.rs Outdated
label: String,
}

/// Keys mirror the config's serde paths (`llm.model`).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk what this comment means but it worries me as we should not be writing any of this twice

Comment thread server-rs/src/api/settings_schema.rs Outdated
key: "server",
label: "Server",
fields: vec![
string("server.display_name", "Display Name",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These substrings should be computed

Comment thread server-rs/src/api/settings_schema.rs Outdated

/// The serde variant names of a fieldless enum, read from its JSON schema so a
/// dropdown can't drift from the enum.
fn enum_variants<T: JsonSchema>() -> Vec<String> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what the comment is saying, nor why we need this specifically enum output

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is to read the variants? There are simpler macros that can extract the enum names for you, and I would not pull in schemars for that

Comment thread server-rs/src/api/settings_schema.rs Outdated

/// Friendly label for a provider wire name; unknown/new variants fall back to
/// the wire name so they still appear in the menu.
fn provider_label(value: &str) -> String {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a method on the enum itself

Comment thread server-rs/Cargo.toml Outdated
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
uuid = { version = "1", features = ["v4"] }
schemars = "1"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As said above, I don't think we need this dependency

@chrispmonkey

Copy link
Copy Markdown
Contributor Author

Makes sense. I'll model Field as an enum with embedded per-variant data (value only on non-secrets, isSet on secrets), drop schemars for a small variant-name macro, and move the provider labels onto LlmProvider as a method.

One check before I rebuild: for the visibleWhen / parent-enum point — are you picturing the descriptor derived from a Rust "parent enum → per-variant sub-fields" model rather than the hand-listed fields it is now? Want to redo it in the direction you mean rather than guess.

@agg23

agg23 commented Aug 19, 2026

Copy link
Copy Markdown
Member

One check before I rebuild: for the visibleWhen / parent-enum point — are you picturing the descriptor derived from a Rust "parent enum → per-variant sub-fields" model rather than the hand-listed fields it is now?

I'm sorry, I'm not sure what this means. Let Serde decide the payload shape. It has it's ways to serialize enums; that's all we should need. You can add custom getters on the serialization for the provider_label style field you should add to those enum impls.

Per review: serde tags the field payload (value on scalars, isSet on secrets)
instead of a struct of options; the provider label and variant list move onto
LlmProvider (dropping schemars for strum); field keys are computed from the
section.
@chrispmonkey

Copy link
Copy Markdown
Contributor Author

Reworked per your note — pushed as a follow-up commit. Field is now a struct with a flattened, serde-tagged FieldKind enum, so serde emits the payload shape (value on the scalars, isSet on secrets). Provider label + variant list moved onto LlmProvider (dropped schemars for strum), and the field keys are computed from the section prefix. Left the field names spelled out rather than deriving them from Config — happy to add a derive macro for that if you'd prefer.

@agg23

agg23 commented Sep 4, 2026

Copy link
Copy Markdown
Member

I'm really sorry for the delay. I think you can do this entirely in Serde, with no added dependencies, but it requires abusing Deserialize.

You don't have to do the above, but all of the fields should fall automatically (via macro) from the core config type. We shouldn't have to specify the paths and types and stuff; Rust already has that info.

@chrispmonkey

Copy link
Copy Markdown
Contributor Author

Thanks — and no worries on the timing. Agreed the field list shouldn't be hand-maintained.

My lean is a #[derive(SettingsSchema)] proc-macro on the config structs: the paths and types fall straight out of the struct, and field attributes carry the bits Rust can't infer — label, secret, visibleWhen, enum options.

I'd take that over the pure-Serde Deserialize trace mainly because that path can only recover field names/types as strings — the UI metadata (labels, secret-ness, visibleWhen) has nowhere to live on the Serde side, so it ends up in a separate path-keyed table that desyncs silently on a rename, which recreates the duplication you're calling out.

Only honest tradeoff: the derive has to be its own proc-macro crate (syn/quote as build deps), so it turns server-rs into a small workspace — more structure than the Serde trick, but the standard derive toolchain rather than a runtime lib like schemars/strum. Good with that, or would you rather keep it in-crate with the pure-Serde approach?

@agg23

agg23 commented Sep 8, 2026

Copy link
Copy Markdown
Member

I feel like this should be a solved problem. I would hate to reinvent the wheel (or rather, have you do it) for this tiny little project used by a few people.

I think a proc-macro can indeed do this without too much trouble. It may just be worth doing that

@chrispmonkey

Copy link
Copy Markdown
Contributor Author

Checked the ecosystem first. Closest are ALVR's settings-schema (struct→JSON for a non-Rust GUI, but unmaintained since 2023 — no labels, secrets, enum-labels, or arbitrary cross-field visibility) and rcman (has labels/secret/enum-options, but no visibleWhen, and it's brand-new + single-maintainer with keychain-bound secrets). schemars structurally can't express label/secret/visibleWhen. So the 90% is scattered across single-purpose crates; the cross-field visibleWhen + write-only-secret shape as one descriptor is the part we'd own regardless.

I'll do it as a small derive on the config types — which also lets the derive enumerate the provider variants itself, so strum comes back out. Push as a follow-up commit.

A SettingsFields derive on the config structs generates each field's key,
type, and value; SettingsOptions does the enum dropdowns. Only the UI facts a
type can't carry — label, secret/enum/text, visibleWhen — are #[setting]
attributes, so fields aren't listed twice. Drops the strum dep.
@chrispmonkey

Copy link
Copy Markdown
Contributor Author

Pushed the derive as a follow-up commit. #[derive(SettingsFields)] on the config structs generates each field's key/type/value; #[derive(SettingsOptions)] on LlmProvider does the dropdown. The only per-field annotations are the things the type can't carry — label, secret/enum/text, and visibleWhen — so nothing's listed twice.

It's its own small proc-macro crate (server-rs becomes a 2-member workspace), and strum is back out. Output matches the previous descriptor, except one field's order now follows the struct.

Lock the full descriptor inventory (every section/field/type/label) and the
getter-backed prompt values in tests, and have the derive report bad
#[setting] attributes as compile errors instead of panicking.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants