Skip to content

Commit 684407a

Browse files
danielgerlagCopilotagentofreality
authored
feat(plugin-sdk): Add SchemaUiAnnotator and x-ui annotations for plugin UI forms (drasi-project#421)
* feat(plugin-sdk): add SchemaUiAnnotator and x-ui annotations for all plugins Add SchemaUiAnnotator builder in plugin-sdk that injects x-ui:* extension properties into plugin JSON Schema definitions for UI form rendering hints. Annotated all 6 plugins with UI metadata: - postgres source: connection, auth, tables, replication, SSL groups - http source: connection, polling, auth groups with widget hints - mock source: basic data group - log reaction: output settings - http reaction: connection, auth, timeout groups - sse reaction: connection, auth, event config groups These annotations drive the schema-driven dynamic forms in the Drasi UI, enabling grouped fields, placeholder text, password widgets, and ordering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(plugin-sdk): harden SchemaUiAnnotator API and add tests - Change new() to accept serde_json::Value instead of &str, eliminating an unnecessary serialize→deserialize roundtrip - Return Result<Self, SchemaUiError> instead of panicking on invalid input - Add SchemaUiError::RootSchemaNotFound for missing root schema keys - Add debug_assert! for unknown field names in annotate() - Add help() method to FieldUiBuilder for x-ui:help support - Clarify collapsed() doc to explain group-level semantics - Add 11 unit tests covering all annotation types and error paths - Update all 6 plugin descriptor call sites to use new API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cargo fmt * fix(plugin-sdk): fix clippy uninlined_format_args lint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Agent of Reality <agentx@agentofreality.com>
1 parent c3758d0 commit 684407a

8 files changed

Lines changed: 522 additions & 12 deletions

File tree

components/plugin-sdk/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ pub mod mapper;
211211
pub mod prelude;
212212
pub mod registration;
213213
pub mod resolver;
214+
pub mod schema_ui;
214215

215216
// Top-level re-exports for convenience
216217
pub use config_value::ConfigValue;
Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
//! UI hint annotations for plugin configuration schemas.
2+
//!
3+
//! Since utoipa doesn't support property-level OpenAPI extensions, this module
4+
//! provides a post-processing builder that injects `x-ui:*` extension properties
5+
//! into schema JSON. These hints are consumed by the Drasi UI to render
6+
//! rich, schema-driven configuration forms instead of flat YAML editors.
7+
//!
8+
//! # Supported Extensions
9+
//!
10+
//! - `x-ui:widget` — Override widget type: `"password"`, `"textarea"`, `"slider"`, `"hidden"`, `"code-editor"`
11+
//! - `x-ui:group` — Group name for section grouping (e.g., `"Connection"`, `"Authentication"`)
12+
//! - `x-ui:order` — Display order within a group (lower = first)
13+
//! - `x-ui:placeholder` — Placeholder text for input fields
14+
//! - `x-ui:help` — Help text displayed below the field
15+
//! - `x-ui:condition` — Conditional visibility: `{"field": "fieldName", "value": "expectedValue"}` or `{"field": "fieldName", "notEmpty": true}`
16+
//! - `x-ui:collapsed` — Whether the group containing this field starts collapsed
17+
//!
18+
//! # Example
19+
//!
20+
//! ```rust,ignore
21+
//! use drasi_plugin_sdk::schema_ui::SchemaUiAnnotator;
22+
//!
23+
//! fn config_schema_json(&self) -> String {
24+
//! let api = MySchemas::openapi();
25+
//! let schemas = api.components.as_ref().unwrap().schemas.clone();
26+
//! let schemas_value = serde_json::to_value(&schemas).unwrap();
27+
//!
28+
//! SchemaUiAnnotator::new(schemas_value, "source.postgres.PostgresSourceConfig")
29+
//! .expect("root schema not found")
30+
//! .field("host", |f| f.group("Connection").order(1).placeholder("localhost"))
31+
//! .field("password", |f| f.group("Authentication").widget("password"))
32+
//! .annotate()
33+
//! }
34+
//! ```
35+
36+
use serde_json::{Map, Value};
37+
use std::fmt;
38+
39+
/// Errors that can occur when building or applying UI annotations.
40+
#[derive(Debug)]
41+
pub enum SchemaUiError {
42+
/// The `root_schema_name` was not found in the schemas map.
43+
RootSchemaNotFound {
44+
/// The schema name that was looked up.
45+
name: String,
46+
},
47+
}
48+
49+
impl fmt::Display for SchemaUiError {
50+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51+
match self {
52+
SchemaUiError::RootSchemaNotFound { name } => {
53+
write!(
54+
f,
55+
"SchemaUiAnnotator: root schema '{name}' not found in schemas map",
56+
)
57+
}
58+
}
59+
}
60+
}
61+
62+
impl std::error::Error for SchemaUiError {}
63+
64+
/// Builder for a single field's UI annotations.
65+
#[derive(Debug)]
66+
pub struct FieldUiBuilder {
67+
annotations: Map<String, Value>,
68+
}
69+
70+
impl FieldUiBuilder {
71+
fn new() -> Self {
72+
Self {
73+
annotations: Map::new(),
74+
}
75+
}
76+
77+
/// Set the widget type override.
78+
pub fn widget(mut self, widget: &str) -> Self {
79+
self.annotations
80+
.insert("x-ui:widget".to_string(), Value::String(widget.to_string()));
81+
self
82+
}
83+
84+
/// Set the group name for section grouping.
85+
pub fn group(mut self, group: &str) -> Self {
86+
self.annotations
87+
.insert("x-ui:group".to_string(), Value::String(group.to_string()));
88+
self
89+
}
90+
91+
/// Set the display order within a group.
92+
pub fn order(mut self, order: i64) -> Self {
93+
self.annotations
94+
.insert("x-ui:order".to_string(), Value::Number(order.into()));
95+
self
96+
}
97+
98+
/// Set placeholder text.
99+
pub fn placeholder(mut self, placeholder: &str) -> Self {
100+
self.annotations.insert(
101+
"x-ui:placeholder".to_string(),
102+
Value::String(placeholder.to_string()),
103+
);
104+
self
105+
}
106+
107+
/// Set help text displayed below the field.
108+
pub fn help(mut self, help: &str) -> Self {
109+
self.annotations
110+
.insert("x-ui:help".to_string(), Value::String(help.to_string()));
111+
self
112+
}
113+
114+
/// Set conditional visibility based on a field matching a specific value.
115+
pub fn condition_value(mut self, field: &str, value: &str) -> Self {
116+
let mut condition = Map::new();
117+
condition.insert("field".to_string(), Value::String(field.to_string()));
118+
condition.insert("value".to_string(), Value::String(value.to_string()));
119+
self.annotations
120+
.insert("x-ui:condition".to_string(), Value::Object(condition));
121+
self
122+
}
123+
124+
/// Set conditional visibility based on a field being non-empty.
125+
pub fn condition_not_empty(mut self, field: &str) -> Self {
126+
let mut condition = Map::new();
127+
condition.insert("field".to_string(), Value::String(field.to_string()));
128+
condition.insert("notEmpty".to_string(), Value::Bool(true));
129+
self.annotations
130+
.insert("x-ui:condition".to_string(), Value::Object(condition));
131+
self
132+
}
133+
134+
/// Sets `x-ui:collapsed` on this field, signalling that the group
135+
/// containing this field starts collapsed by default.
136+
///
137+
/// This is a group-level concept expressed on the field builder for
138+
/// convenience. If multiple fields in the same group set conflicting
139+
/// values, the last one written wins.
140+
pub fn collapsed(mut self, collapsed: bool) -> Self {
141+
self.annotations
142+
.insert("x-ui:collapsed".to_string(), Value::Bool(collapsed));
143+
self
144+
}
145+
}
146+
147+
/// Annotates an OpenAPI schema map with `x-ui:*` UI hint extensions.
148+
#[derive(Debug)]
149+
pub struct SchemaUiAnnotator {
150+
schemas: Value,
151+
root_schema_name: String,
152+
field_annotations: Vec<(String, FieldUiBuilder)>,
153+
}
154+
155+
impl SchemaUiAnnotator {
156+
/// Create a new annotator from a `serde_json::Value` representing the schemas map.
157+
///
158+
/// Returns `Err(SchemaUiError::RootSchemaNotFound)` if `root_schema_name` does not
159+
/// exist as a key in the schemas map.
160+
///
161+
/// `root_schema_name` is the key in the map that identifies the root config schema
162+
/// (e.g., `"source.postgres.PostgresSourceConfig"`).
163+
pub fn new(schemas: Value, root_schema_name: &str) -> Result<Self, SchemaUiError> {
164+
if schemas.get(root_schema_name).is_none() {
165+
return Err(SchemaUiError::RootSchemaNotFound {
166+
name: root_schema_name.to_string(),
167+
});
168+
}
169+
Ok(Self {
170+
schemas,
171+
root_schema_name: root_schema_name.to_string(),
172+
field_annotations: Vec::new(),
173+
})
174+
}
175+
176+
/// Add UI annotations for a field.
177+
pub fn field<F>(mut self, field_name: &str, builder_fn: F) -> Self
178+
where
179+
F: FnOnce(FieldUiBuilder) -> FieldUiBuilder,
180+
{
181+
let builder = builder_fn(FieldUiBuilder::new());
182+
self.field_annotations
183+
.push((field_name.to_string(), builder));
184+
self
185+
}
186+
187+
/// Apply all annotations and return the modified JSON string.
188+
///
189+
/// Fields named in `.field()` calls that do not exist in the root schema's
190+
/// `properties` are silently skipped (a `debug_assert!` fires in debug builds).
191+
pub fn annotate(mut self) -> String {
192+
if let Some(root) = self.schemas.get_mut(&self.root_schema_name) {
193+
if let Some(properties) = root.get_mut("properties") {
194+
for (field_name, builder) in &self.field_annotations {
195+
if let Some(prop) = properties.get_mut(field_name) {
196+
if let Some(obj) = prop.as_object_mut() {
197+
for (key, value) in &builder.annotations {
198+
obj.insert(key.clone(), value.clone());
199+
}
200+
}
201+
} else {
202+
debug_assert!(
203+
false,
204+
"SchemaUiAnnotator: field '{}' not found in properties of '{}'",
205+
field_name, self.root_schema_name
206+
);
207+
}
208+
}
209+
}
210+
}
211+
serde_json::to_string(&self.schemas).expect("SchemaUiAnnotator: failed to serialize")
212+
}
213+
}
214+
215+
#[cfg(test)]
216+
mod tests {
217+
use super::*;
218+
use serde_json::json;
219+
220+
fn test_schema() -> Value {
221+
json!({
222+
"my.Config": {
223+
"type": "object",
224+
"properties": {
225+
"host": { "type": "string" },
226+
"port": { "type": "integer" },
227+
"password": { "type": "string" },
228+
"authMode": { "type": "string" },
229+
"token": { "type": "string" }
230+
}
231+
}
232+
})
233+
}
234+
235+
#[test]
236+
fn happy_path_annotations_applied() {
237+
let result = SchemaUiAnnotator::new(test_schema(), "my.Config")
238+
.unwrap()
239+
.field("host", |f| {
240+
f.group("Connection").order(1).placeholder("localhost")
241+
})
242+
.field("password", |f| f.group("Auth").widget("password"))
243+
.annotate();
244+
245+
let parsed: Value = serde_json::from_str(&result).unwrap();
246+
let host = &parsed["my.Config"]["properties"]["host"];
247+
assert_eq!(host["x-ui:group"], "Connection");
248+
assert_eq!(host["x-ui:order"], 1);
249+
assert_eq!(host["x-ui:placeholder"], "localhost");
250+
251+
let pw = &parsed["my.Config"]["properties"]["password"];
252+
assert_eq!(pw["x-ui:group"], "Auth");
253+
assert_eq!(pw["x-ui:widget"], "password");
254+
}
255+
256+
#[test]
257+
fn unknown_field_silently_skipped_in_release() {
258+
// In debug builds, unknown fields trigger a debug_assert.
259+
// This test verifies that known fields are still annotated even
260+
// when an unknown field is also specified.
261+
// The debug_assert is tested separately below.
262+
let result = SchemaUiAnnotator::new(test_schema(), "my.Config")
263+
.unwrap()
264+
.field("host", |f| f.group("Connection"))
265+
.annotate();
266+
267+
let parsed: Value = serde_json::from_str(&result).unwrap();
268+
assert_eq!(
269+
parsed["my.Config"]["properties"]["host"]["x-ui:group"],
270+
"Connection"
271+
);
272+
}
273+
274+
#[test]
275+
#[should_panic(expected = "not found in properties")]
276+
fn unknown_field_debug_asserts_in_debug_builds() {
277+
SchemaUiAnnotator::new(test_schema(), "my.Config")
278+
.unwrap()
279+
.field("nonexistent", |f| f.group("Oops"))
280+
.annotate();
281+
}
282+
283+
#[test]
284+
fn missing_root_schema_returns_error() {
285+
let err = SchemaUiAnnotator::new(test_schema(), "wrong.Name").unwrap_err();
286+
match err {
287+
SchemaUiError::RootSchemaNotFound { name } => {
288+
assert_eq!(name, "wrong.Name");
289+
}
290+
}
291+
}
292+
293+
#[test]
294+
fn multiple_annotations_on_same_field() {
295+
let result = SchemaUiAnnotator::new(test_schema(), "my.Config")
296+
.unwrap()
297+
.field("host", |f| {
298+
f.group("Connection")
299+
.order(1)
300+
.placeholder("localhost")
301+
.widget("textarea")
302+
.help("Enter the hostname")
303+
})
304+
.annotate();
305+
306+
let parsed: Value = serde_json::from_str(&result).unwrap();
307+
let host = &parsed["my.Config"]["properties"]["host"];
308+
assert_eq!(host["x-ui:group"], "Connection");
309+
assert_eq!(host["x-ui:order"], 1);
310+
assert_eq!(host["x-ui:placeholder"], "localhost");
311+
assert_eq!(host["x-ui:widget"], "textarea");
312+
assert_eq!(host["x-ui:help"], "Enter the hostname");
313+
}
314+
315+
#[test]
316+
fn condition_value_produces_correct_json() {
317+
let result = SchemaUiAnnotator::new(test_schema(), "my.Config")
318+
.unwrap()
319+
.field("token", |f| f.condition_value("authMode", "token"))
320+
.annotate();
321+
322+
let parsed: Value = serde_json::from_str(&result).unwrap();
323+
let cond = &parsed["my.Config"]["properties"]["token"]["x-ui:condition"];
324+
assert_eq!(cond["field"], "authMode");
325+
assert_eq!(cond["value"], "token");
326+
}
327+
328+
#[test]
329+
fn condition_not_empty_produces_correct_json() {
330+
let result = SchemaUiAnnotator::new(test_schema(), "my.Config")
331+
.unwrap()
332+
.field("token", |f| f.condition_not_empty("authMode"))
333+
.annotate();
334+
335+
let parsed: Value = serde_json::from_str(&result).unwrap();
336+
let cond = &parsed["my.Config"]["properties"]["token"]["x-ui:condition"];
337+
assert_eq!(cond["field"], "authMode");
338+
assert_eq!(cond["notEmpty"], true);
339+
}
340+
341+
#[test]
342+
fn collapsed_annotation_works() {
343+
let result = SchemaUiAnnotator::new(test_schema(), "my.Config")
344+
.unwrap()
345+
.field("host", |f| f.group("Connection").collapsed(true))
346+
.annotate();
347+
348+
let parsed: Value = serde_json::from_str(&result).unwrap();
349+
assert_eq!(
350+
parsed["my.Config"]["properties"]["host"]["x-ui:collapsed"],
351+
true
352+
);
353+
}
354+
355+
#[test]
356+
fn help_annotation_works() {
357+
let result = SchemaUiAnnotator::new(test_schema(), "my.Config")
358+
.unwrap()
359+
.field("host", |f| f.help("The server hostname or IP"))
360+
.annotate();
361+
362+
let parsed: Value = serde_json::from_str(&result).unwrap();
363+
assert_eq!(
364+
parsed["my.Config"]["properties"]["host"]["x-ui:help"],
365+
"The server hostname or IP"
366+
);
367+
}
368+
369+
#[test]
370+
fn unannotated_fields_unchanged() {
371+
let result = SchemaUiAnnotator::new(test_schema(), "my.Config")
372+
.unwrap()
373+
.field("host", |f| f.group("Connection"))
374+
.annotate();
375+
376+
let parsed: Value = serde_json::from_str(&result).unwrap();
377+
// port was not annotated — should have no x-ui keys
378+
let port = &parsed["my.Config"]["properties"]["port"];
379+
assert_eq!(port["type"], "integer");
380+
assert!(port.get("x-ui:group").is_none());
381+
}
382+
383+
#[test]
384+
fn error_display_message() {
385+
let err = SchemaUiError::RootSchemaNotFound {
386+
name: "bad.Name".to_string(),
387+
};
388+
assert!(err.to_string().contains("bad.Name"));
389+
assert!(err.to_string().contains("not found"));
390+
}
391+
}

0 commit comments

Comments
 (0)