Add a self-describing settings descriptor endpoint - #10
Conversation
| @@ -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 | |||
| State(state): State<ApiState>, | ||
| Json(changes): Json<Map<String, Value>>, | ||
| ) -> Response { | ||
| let body: super::UpdateSettingsRequest = match serde_json::from_value(nest_dotted(changes)) { |
There was a problem hiding this comment.
This is pretty weird. Why not construct the actual type rather than deserializing then reserializing it?
| json!({ | ||
| "sections": [ | ||
| section("server", "Server", vec![ | ||
| text("server.display_name", "Display Name", |
There was a problem hiding this comment.
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
| Text, | ||
| } | ||
|
|
||
| fn section(key: &str, label: &str, fields: Vec<Value>) -> Value { |
There was a problem hiding this comment.
All of this should really be Serde serializers
| /// 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(); |
There was a problem hiding this comment.
Seems like a implementation you will use often. Not just for LlmProvider
325b542 to
9f1cfbd
Compare
|
Reworked per your notes — the descriptor is now |
| value: Option<Value>, | ||
| /// `secret` only: a value is set, without revealing it. | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| configured: Option<bool>, |
There was a problem hiding this comment.
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.
| 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>>, |
There was a problem hiding this comment.
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
| label: String, | ||
| } | ||
|
|
||
| /// Keys mirror the config's serde paths (`llm.model`). |
There was a problem hiding this comment.
idk what this comment means but it worries me as we should not be writing any of this twice
| key: "server", | ||
| label: "Server", | ||
| fields: vec![ | ||
| string("server.display_name", "Display Name", |
There was a problem hiding this comment.
These substrings should be computed
|
|
||
| /// 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> { |
There was a problem hiding this comment.
I don't know what the comment is saying, nor why we need this specifically enum output
There was a problem hiding this comment.
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
|
|
||
| /// 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 { |
There was a problem hiding this comment.
This should be a method on the enum itself
| tracing-subscriber = { version = "0.3", features = ["env-filter"] } | ||
| tracing-appender = "0.2" | ||
| uuid = { version = "1", features = ["v4"] } | ||
| schemars = "1" |
There was a problem hiding this comment.
As said above, I don't think we need this dependency
|
Makes sense. I'll model 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. |
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 |
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.
|
Reworked per your note — pushed as a follow-up commit. |
|
I'm really sorry for the delay. I think you can do this entirely in Serde, with no added dependencies, but it requires abusing 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. |
|
Thanks — and no worries on the timing. Agreed the field list shouldn't be hand-maintained. My lean is a I'd take that over the pure-Serde 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? |
|
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 |
|
Checked the ecosystem first. Closest are ALVR's I'll do it as a small |
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.
|
Pushed the derive as a follow-up commit. It's its own small proc-macro crate (server-rs becomes a 2-member workspace), and |
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.
Adds
GET /api/settings/schema— the device's settings reported as sections oftyped fields (
string/text/secret/bool/enum, each with UImetadata) 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 theencoding. Enum options derive from the config enum via
schemars(a genericenum_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.