1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value, json};
3use thiserror::Error;
4
5use qa_spec::{
6 FormSpec, ProgressContext, RenderPayload, StoreContext, StoreError, VisibilityMode,
7 answers_schema, build_render_payload, example_answers, next_question,
8 render_card as qa_render_card, render_json_ui as qa_render_json_ui,
9 render_text as qa_render_text, resolve_visibility, validate,
10};
11
12const DEFAULT_SPEC: &str = include_str!("../tests/fixtures/simple_form.json");
13
14#[derive(Debug, Error)]
15enum ComponentError {
16 #[error("failed to parse config/{0}")]
17 ConfigParse(#[source] serde_json::Error),
18 #[error("form '{0}' is not available")]
19 FormUnavailable(String),
20 #[error("json encode error: {0}")]
21 JsonEncode(#[source] serde_json::Error),
22 #[error("store apply failed: {0}")]
23 Store(#[from] StoreError),
24}
25
26#[derive(Debug, Deserialize, Serialize, Default)]
27struct ComponentConfig {
28 #[serde(default)]
29 form_spec_json: Option<String>,
30}
31
32fn load_form_spec(config_json: &str) -> Result<FormSpec, ComponentError> {
33 let config = if config_json.trim().is_empty() {
34 ComponentConfig::default()
35 } else {
36 serde_json::from_str(config_json).map_err(ComponentError::ConfigParse)?
37 };
38
39 let spec_json = config.form_spec_json.as_deref().unwrap_or(DEFAULT_SPEC);
40
41 serde_json::from_str(spec_json).map_err(ComponentError::ConfigParse)
42}
43
44fn parse_context(ctx_json: &str) -> Value {
45 serde_json::from_str(ctx_json).unwrap_or_else(|_| Value::Object(Map::new()))
46}
47
48fn resolve_context_answers(ctx: &Value) -> Value {
49 ctx.get("answers")
50 .cloned()
51 .unwrap_or_else(|| Value::Object(Map::new()))
52}
53
54fn parse_answers(answers_json: &str) -> Value {
55 serde_json::from_str(answers_json).unwrap_or_else(|_| Value::Object(Map::new()))
56}
57
58fn secrets_host_available(ctx: &Value) -> bool {
59 ctx.get("secrets_host_available")
60 .and_then(Value::as_bool)
61 .or_else(|| {
62 ctx.get("config")
63 .and_then(Value::as_object)
64 .and_then(|config| config.get("secrets_host_available"))
65 .and_then(Value::as_bool)
66 })
67 .unwrap_or(false)
68}
69
70fn respond(result: Result<Value, ComponentError>) -> String {
71 match result {
72 Ok(value) => serde_json::to_string(&value).unwrap_or_else(|error| {
73 json!({"error": format!("json encode: {}", error)}).to_string()
74 }),
75 Err(err) => json!({ "error": err.to_string() }).to_string(),
76 }
77}
78
79pub fn describe(form_id: &str, config_json: &str) -> String {
80 respond(load_form_spec(config_json).and_then(|spec| {
81 if spec.id != form_id {
82 Err(ComponentError::FormUnavailable(form_id.to_string()))
83 } else {
84 serde_json::to_value(spec).map_err(ComponentError::JsonEncode)
85 }
86 }))
87}
88
89fn ensure_form(form_id: &str, config_json: &str) -> Result<FormSpec, ComponentError> {
90 let spec = load_form_spec(config_json)?;
91 if spec.id != form_id {
92 Err(ComponentError::FormUnavailable(form_id.to_string()))
93 } else {
94 Ok(spec)
95 }
96}
97
98pub fn get_answer_schema(form_id: &str, config_json: &str, ctx_json: &str) -> String {
99 let schema = ensure_form(form_id, config_json).map(|spec| {
100 let ctx = parse_context(ctx_json);
101 let answers = resolve_context_answers(&ctx);
102 let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
103 answers_schema(&spec, &visibility)
104 });
105 respond(schema)
106}
107
108pub fn get_example_answers(form_id: &str, config_json: &str, ctx_json: &str) -> String {
109 let result = ensure_form(form_id, config_json).map(|spec| {
110 let ctx = parse_context(ctx_json);
111 let answers = resolve_context_answers(&ctx);
112 let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
113 example_answers(&spec, &visibility)
114 });
115 respond(result)
116}
117
118pub fn validate_answers(form_id: &str, config_json: &str, answers_json: &str) -> String {
119 let validation = ensure_form(form_id, config_json).and_then(|spec| {
120 let answers = serde_json::from_str(answers_json).map_err(ComponentError::ConfigParse)?;
121 serde_json::to_value(validate(&spec, &answers)).map_err(ComponentError::JsonEncode)
122 });
123 respond(validation)
124}
125
126pub fn next(form_id: &str, ctx_json: &str, answers_json: &str) -> String {
127 let result = ensure_form(form_id, ctx_json).map(|spec| {
128 let ctx = parse_context(ctx_json);
129 let answers = parse_answers(answers_json);
130 let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
131 let progress_ctx = ProgressContext::new(answers.clone(), &ctx);
132 let next_q = next_question(&spec, &progress_ctx, &visibility);
133 let answered = progress_ctx.answered_count(&spec, &visibility);
134 let total = visibility.values().filter(|visible| **visible).count();
135 json!({
136 "status": if next_q.is_some() { "need_input" } else { "complete" },
137 "next_question_id": next_q,
138 "progress": {
139 "answered": answered,
140 "total": total
141 }
142 })
143 });
144 respond(result)
145}
146
147pub fn apply_store(form_id: &str, ctx_json: &str, answers_json: &str) -> String {
148 let result = ensure_form(form_id, ctx_json).and_then(|spec| {
149 let ctx = parse_context(ctx_json);
150 let answers = parse_answers(answers_json);
151 let mut store_ctx = StoreContext::from_value(&ctx);
152 store_ctx.answers = answers;
153 let host_available = secrets_host_available(&ctx);
154 store_ctx.apply_ops(&spec.store, spec.secrets_policy.as_ref(), host_available)?;
155 Ok(store_ctx.to_value())
156 });
157 respond(result)
158}
159
160fn render_payload(
161 form_id: &str,
162 config_json: &str,
163 ctx_json: &str,
164 answers_json: &str,
165) -> Result<RenderPayload, ComponentError> {
166 let spec = ensure_form(form_id, config_json)?;
167 let ctx = parse_context(ctx_json);
168 let answers = parse_answers(answers_json);
169 Ok(build_render_payload(&spec, &ctx, &answers))
170}
171
172fn respond_string(result: Result<String, ComponentError>) -> String {
173 match result {
174 Ok(value) => value,
175 Err(err) => json!({ "error": err.to_string() }).to_string(),
176 }
177}
178
179pub fn render_text(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
180 respond_string(
181 render_payload(form_id, config_json, ctx_json, answers_json)
182 .map(|payload| qa_render_text(&payload)),
183 )
184}
185
186pub fn render_json_ui(
187 form_id: &str,
188 config_json: &str,
189 ctx_json: &str,
190 answers_json: &str,
191) -> String {
192 respond(
193 render_payload(form_id, config_json, ctx_json, answers_json)
194 .map(|payload| qa_render_json_ui(&payload)),
195 )
196}
197
198pub fn render_card(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
199 respond(
200 render_payload(form_id, config_json, ctx_json, answers_json)
201 .map(|payload| qa_render_card(&payload)),
202 )
203}
204
205fn submission_progress(payload: &RenderPayload) -> Value {
206 json!({
207 "answered": payload.progress.answered,
208 "total": payload.progress.total,
209 })
210}
211
212fn build_error_response(
213 payload: &RenderPayload,
214 answers: Value,
215 validation: &qa_spec::ValidationResult,
216) -> Result<Value, ComponentError> {
217 let validation_value = serde_json::to_value(validation).map_err(ComponentError::JsonEncode)?;
218 Ok(json!({
219 "status": "error",
220 "next_question_id": payload.next_question_id,
221 "progress": submission_progress(payload),
222 "answers": answers,
223 "validation": validation_value,
224 }))
225}
226
227fn build_success_response(
228 payload: &RenderPayload,
229 answers: Value,
230 store_ctx: &StoreContext,
231) -> Value {
232 let status = if payload.next_question_id.is_some() {
233 "need_input"
234 } else {
235 "complete"
236 };
237
238 json!({
239 "status": status,
240 "next_question_id": payload.next_question_id,
241 "progress": submission_progress(payload),
242 "answers": answers,
243 "store": store_ctx.to_value(),
244 })
245}
246
247fn with_answers_mutated(answers_json: &str, question_id: &str, value: Value) -> Value {
248 let mut map = parse_answers(answers_json)
249 .as_object()
250 .cloned()
251 .unwrap_or_default();
252 map.insert(question_id.to_string(), value);
253 Value::Object(map)
254}
255
256pub fn submit_patch(
257 form_id: &str,
258 config_json: &str,
259 ctx_json: &str,
260 answers_json: &str,
261 question_id: &str,
262 value_json: &str,
263) -> String {
264 respond(ensure_form(form_id, config_json).and_then(|spec| {
265 let ctx = parse_context(ctx_json);
266 let value: Value = serde_json::from_str(value_json).map_err(ComponentError::ConfigParse)?;
267 let answers = with_answers_mutated(answers_json, question_id, value);
268 let validation = validate(&spec, &answers);
269 let payload = build_render_payload(&spec, &ctx, &answers);
270
271 if !validation.valid {
272 return build_error_response(&payload, answers, &validation);
273 }
274
275 let mut store_ctx = StoreContext::from_value(&ctx);
276 store_ctx.answers = answers.clone();
277 let host_available = secrets_host_available(&ctx);
278 store_ctx.apply_ops(&spec.store, spec.secrets_policy.as_ref(), host_available)?;
279 let response = build_success_response(&payload, answers, &store_ctx);
280 Ok(response)
281 }))
282}
283
284pub fn submit_all(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
285 respond(ensure_form(form_id, config_json).and_then(|spec| {
286 let ctx = parse_context(ctx_json);
287 let answers = parse_answers(answers_json);
288 let validation = validate(&spec, &answers);
289 let payload = build_render_payload(&spec, &ctx, &answers);
290
291 if !validation.valid {
292 return build_error_response(&payload, answers, &validation);
293 }
294
295 let mut store_ctx = StoreContext::from_value(&ctx);
296 store_ctx.answers = answers.clone();
297 let host_available = secrets_host_available(&ctx);
298 store_ctx.apply_ops(&spec.store, spec.secrets_policy.as_ref(), host_available)?;
299 let response = build_success_response(&payload, answers, &store_ctx);
300 Ok(response)
301 }))
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use serde_json::json;
308
309 #[test]
310 fn describe_returns_spec_json() {
311 let payload = describe("example-form", "");
312 let spec: Value = serde_json::from_str(&payload).expect("valid json");
313 assert_eq!(spec["id"], "example-form");
314 }
315
316 #[test]
317 fn schema_matches_questions() {
318 let schema = get_answer_schema("example-form", "", "{}");
319 let value: Value = serde_json::from_str(&schema).expect("json");
320 assert!(
321 value
322 .get("properties")
323 .unwrap()
324 .as_object()
325 .unwrap()
326 .contains_key("q1")
327 );
328 }
329
330 #[test]
331 fn example_answers_include_question_values() {
332 let examples = get_example_answers("example-form", "", "{}");
333 let parsed: Value = serde_json::from_str(&examples).expect("json");
334 assert_eq!(parsed["q1"], "example-q1");
335 }
336
337 #[test]
338 fn validate_answers_reports_valid_when_complete() {
339 let answers = json!({ "q1": "tester", "q2": true });
340 let result = validate_answers("example-form", "", &answers.to_string());
341 let parsed: Value = serde_json::from_str(&result).expect("json");
342 assert!(parsed["valid"].as_bool().unwrap_or(false));
343 }
344
345 #[test]
346 fn next_returns_progress_payload() {
347 let spec = json!({
348 "id": "progress-form",
349 "title": "Progress",
350 "version": "1.0",
351 "progress_policy": {
352 "skip_answered": true
353 },
354 "questions": [
355 { "id": "q1", "type": "string", "title": "q1", "required": true },
356 { "id": "q2", "type": "string", "title": "q2", "required": true }
357 ]
358 });
359 let ctx = json!({ "form_spec_json": spec.to_string() });
360 let response = next("progress-form", &ctx.to_string(), r#"{"q1": "test"}"#);
361 let parsed: Value = serde_json::from_str(&response).expect("json");
362 assert_eq!(parsed["status"], "need_input");
363 assert_eq!(parsed["next_question_id"], "q2");
364 assert_eq!(parsed["progress"]["answered"], 1);
365 }
366
367 #[test]
368 fn apply_store_writes_state_value() {
369 let spec = json!({
370 "id": "store-form",
371 "title": "Store",
372 "version": "1.0",
373 "questions": [
374 { "id": "q1", "type": "string", "title": "q1", "required": true }
375 ],
376 "store": [
377 {
378 "target": "state",
379 "path": "/flag",
380 "value": true
381 }
382 ]
383 });
384 let ctx = json!({
385 "form_spec_json": spec.to_string(),
386 "state": {}
387 });
388 let result = apply_store("store-form", &ctx.to_string(), "{}");
389 let parsed: Value = serde_json::from_str(&result).expect("json");
390 assert_eq!(parsed["state"]["flag"], true);
391 }
392
393 #[test]
394 fn apply_store_writes_secret_when_allowed() {
395 let spec = json!({
396 "id": "store-secret",
397 "title": "Store Secret",
398 "version": "1.0",
399 "questions": [
400 { "id": "q1", "type": "string", "title": "q1", "required": true }
401 ],
402 "store": [
403 {
404 "target": "secrets",
405 "path": "/aws/key",
406 "value": "value"
407 }
408 ],
409 "secrets_policy": {
410 "enabled": true,
411 "read_enabled": true,
412 "write_enabled": true,
413 "allow": ["aws/*"]
414 }
415 });
416 let ctx = json!({
417 "form_spec_json": spec.to_string(),
418 "state": {},
419 "secrets_host_available": true
420 });
421 let result = apply_store("store-secret", &ctx.to_string(), "{}");
422 let parsed: Value = serde_json::from_str(&result).expect("json");
423 assert_eq!(parsed["secrets"]["aws"]["key"], "value");
424 }
425
426 #[test]
427 fn render_text_outputs_summary() {
428 let output = render_text("example-form", "", "{}", "{}");
429 assert!(output.contains("Form:"));
430 assert!(output.contains("Visible questions"));
431 }
432
433 #[test]
434 fn render_json_ui_outputs_json_payload() {
435 let payload = render_json_ui("example-form", "", "{}", r#"{"q1":"value"}"#);
436 let parsed: Value = serde_json::from_str(&payload).expect("json");
437 assert_eq!(parsed["form_id"], "example-form");
438 assert_eq!(parsed["progress"]["total"], 2);
439 }
440
441 #[test]
442 fn render_card_outputs_patch_action() {
443 let payload = render_card("example-form", "", "{}", "{}");
444 let parsed: Value = serde_json::from_str(&payload).expect("json");
445 assert_eq!(parsed["version"], "1.3");
446 let actions = parsed["actions"].as_array().expect("actions");
447 assert_eq!(actions[0]["data"]["qa"]["mode"], "patch");
448 }
449
450 #[test]
451 fn submit_patch_advances_and_updates_store() {
452 let response = submit_patch("example-form", "", "{}", "{}", "q1", r#""Acme""#);
453 let parsed: Value = serde_json::from_str(&response).expect("json");
454 assert_eq!(parsed["status"], "need_input");
455 assert_eq!(parsed["next_question_id"], "q2");
456 assert_eq!(parsed["answers"]["q1"], "Acme");
457 assert_eq!(parsed["store"]["answers"]["q1"], "Acme");
458 }
459
460 #[test]
461 fn submit_patch_returns_validation_error() {
462 let response = submit_patch("example-form", "", "{}", "{}", "q1", "true");
463 let parsed: Value = serde_json::from_str(&response).expect("json");
464 assert_eq!(parsed["status"], "error");
465 assert_eq!(parsed["validation"]["errors"][0]["code"], "type_mismatch");
466 }
467
468 #[test]
469 fn submit_all_completes_with_valid_answers() {
470 let response = submit_all("example-form", "", "{}", r#"{"q1":"Acme","q2":true}"#);
471 let parsed: Value = serde_json::from_str(&response).expect("json");
472 assert_eq!(parsed["status"], "complete");
473 assert!(parsed["next_question_id"].is_null());
474 assert_eq!(parsed["answers"]["q2"], true);
475 assert_eq!(parsed["store"]["answers"]["q2"], true);
476 }
477}