Skip to main content

gog_forms/
types.rs

1// Google Forms API v1 data types.
2// Matches Google Forms API v1 JSON response shapes.
3// https://developers.google.com/forms/api/reference/rest/v1/forms
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9// ---------------------------------------------------------------------------
10// FormsError
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, thiserror::Error)]
14pub enum FormsError {
15    #[error("Forms API error ({status}): {message}")]
16    Api { status: u16, message: String },
17
18    #[error(transparent)]
19    Http(#[from] reqwest::Error),
20
21    #[error(transparent)]
22    Json(#[from] serde_json::Error),
23}
24
25// ---------------------------------------------------------------------------
26// Form
27// ---------------------------------------------------------------------------
28
29/// A Google Form resource.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct Form {
33    /// The form ID.
34    pub form_id: String,
35
36    /// The form's title and description metadata.
37    pub info: FormInfo,
38
39    /// The list of items (questions, page breaks, etc.) in the form.
40    #[serde(default)]
41    pub items: Vec<Item>,
42
43    /// The published URL that respondents use to fill out the form.
44    #[serde(default)]
45    pub responder_uri: String,
46
47    /// An opaque server-provided revision ID.
48    #[serde(default)]
49    pub revision_id: String,
50
51    /// If set, the form is linked to this Google Sheets spreadsheet ID.
52    pub linked_sheet_id: Option<String>,
53}
54
55impl Form {
56    /// Returns the form title, or "(Untitled)" if the title is absent or empty.
57    pub fn title(&self) -> &str {
58        let t = self.info.title.as_str();
59        if t.is_empty() { "(Untitled)" } else { t }
60    }
61
62    /// Returns the number of items that contain a question (not page breaks, etc.).
63    pub fn question_count(&self) -> usize {
64        self.items.iter().filter(|i| i.is_question()).count()
65    }
66}
67
68// ---------------------------------------------------------------------------
69// FormInfo
70// ---------------------------------------------------------------------------
71
72/// Metadata about a form.
73#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74#[serde(rename_all = "camelCase")]
75pub struct FormInfo {
76    /// The user-visible title shown at the top of the form.
77    #[serde(default)]
78    pub title: String,
79
80    /// The form description shown beneath the title.
81    #[serde(default)]
82    pub description: String,
83
84    /// The title of the underlying Google Doc (may differ from `title`).
85    #[serde(default)]
86    pub document_title: String,
87}
88
89// ---------------------------------------------------------------------------
90// Item
91// ---------------------------------------------------------------------------
92
93/// A single item in a form (question, page break, section header, etc.).
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct Item {
97    /// Opaque server-assigned item ID.
98    #[serde(default)]
99    pub item_id: String,
100
101    /// Item display title.
102    #[serde(default)]
103    pub title: String,
104
105    /// Item description / helper text.
106    #[serde(default)]
107    pub description: String,
108
109    /// Set when this item is a question.
110    pub question_item: Option<QuestionItem>,
111
112    /// Set when this item is a page break.
113    pub page_break_item: Option<serde_json::Value>,
114}
115
116impl Item {
117    /// Returns true if this item contains a question (as opposed to a structural
118    /// element like a page break).
119    pub fn is_question(&self) -> bool {
120        self.question_item.is_some()
121    }
122}
123
124// ---------------------------------------------------------------------------
125// QuestionItem
126// ---------------------------------------------------------------------------
127
128/// Wrapper that holds the question payload for a question-type item.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct QuestionItem {
132    pub question: Question,
133}
134
135// ---------------------------------------------------------------------------
136// Question
137// ---------------------------------------------------------------------------
138
139/// The question inside a QuestionItem.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct Question {
143    /// Opaque server-assigned question ID.
144    #[serde(default)]
145    pub question_id: String,
146
147    /// Whether an answer is required.
148    #[serde(default)]
149    pub required: bool,
150
151    /// Set when this is a multiple-choice / checkbox / dropdown question.
152    pub choice_question: Option<ChoiceQuestion>,
153
154    /// Set when this is a short-answer or paragraph question.
155    pub text_question: Option<TextQuestion>,
156
157    /// Set when this is a linear-scale question.
158    pub scale_question: Option<ScaleQuestion>,
159}
160
161impl Question {
162    /// Returns a human-readable type label for this question.
163    ///
164    /// - `"choice"` – RADIO, CHECKBOX, or DROP_DOWN
165    /// - `"text"`   – short answer or paragraph
166    /// - `"scale"`  – linear scale
167    /// - `"unknown"` – none of the above (image, video, etc.)
168    pub fn question_type(&self) -> &str {
169        if self.choice_question.is_some() {
170            "choice"
171        } else if self.text_question.is_some() {
172            "text"
173        } else if self.scale_question.is_some() {
174            "scale"
175        } else {
176            "unknown"
177        }
178    }
179}
180
181// ---------------------------------------------------------------------------
182// ChoiceQuestion
183// ---------------------------------------------------------------------------
184
185/// A multiple-choice, checkbox, or dropdown question.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct ChoiceQuestion {
189    /// The choice type: `"RADIO"`, `"CHECKBOX"`, or `"DROP_DOWN"`.
190    #[serde(rename = "type")]
191    pub type_: String,
192
193    /// The list of available options.
194    #[serde(default)]
195    pub options: Vec<ChoiceOption>,
196}
197
198// ---------------------------------------------------------------------------
199// ChoiceOption
200// ---------------------------------------------------------------------------
201
202/// A single option inside a ChoiceQuestion.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct ChoiceOption {
206    /// The display value of this option.
207    pub value: String,
208}
209
210// ---------------------------------------------------------------------------
211// TextQuestion
212// ---------------------------------------------------------------------------
213
214/// A short-answer or paragraph text question.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct TextQuestion {
218    /// If true, this is a paragraph (long-answer) question; otherwise short-answer.
219    #[serde(default)]
220    pub paragraph: bool,
221}
222
223// ---------------------------------------------------------------------------
224// ScaleQuestion
225// ---------------------------------------------------------------------------
226
227/// A linear-scale question.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(rename_all = "camelCase")]
230pub struct ScaleQuestion {
231    /// The lowest value on the scale.
232    pub low: i32,
233
234    /// The highest value on the scale.
235    pub high: i32,
236
237    /// Optional label displayed at the low end.
238    pub low_label: Option<String>,
239
240    /// Optional label displayed at the high end.
241    pub high_label: Option<String>,
242}
243
244// ---------------------------------------------------------------------------
245// FormResponse
246// ---------------------------------------------------------------------------
247
248/// A single respondent's answers to a form.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct FormResponse {
252    /// Opaque server-assigned response ID.
253    pub response_id: String,
254
255    /// When the response was first submitted.
256    pub create_time: String,
257
258    /// When the response was last submitted (may differ if edits are allowed).
259    pub last_submitted_time: String,
260
261    /// Email address of the respondent, if the form collected it.
262    pub respondent_email: Option<String>,
263
264    /// Map from question ID to the answer for that question.
265    #[serde(default)]
266    pub answers: HashMap<String, Answer>,
267}
268
269// ---------------------------------------------------------------------------
270// Answer
271// ---------------------------------------------------------------------------
272
273/// An answer to a single question.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct Answer {
277    /// The question this answer belongs to.
278    pub question_id: String,
279
280    /// Text answers (present for most question types).
281    pub text_answers: Option<TextAnswers>,
282}
283
284// ---------------------------------------------------------------------------
285// TextAnswers / TextAnswer
286// ---------------------------------------------------------------------------
287
288/// A collection of text answer values (may be more than one for checkboxes).
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct TextAnswers {
292    #[serde(default)]
293    pub answers: Vec<TextAnswer>,
294}
295
296/// A single text answer value.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct TextAnswer {
300    pub value: String,
301}
302
303// ---------------------------------------------------------------------------
304// ResponseList
305// ---------------------------------------------------------------------------
306
307/// A paginated list of form responses.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct ResponseList {
311    #[serde(default)]
312    pub responses: Vec<FormResponse>,
313
314    pub next_page_token: Option<String>,
315}
316
317// ---------------------------------------------------------------------------
318// Tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    // -----------------------------------------------------------------------
326    // test_form_deserialize
327    // -----------------------------------------------------------------------
328
329    #[test]
330    fn test_form_deserialize() {
331        let json = r#"{
332            "formId": "1FAIpQLSe_abc123",
333            "info": {
334                "title": "Customer Survey",
335                "description": "Please fill in your feedback.",
336                "documentTitle": "Customer Survey (Responses)"
337            },
338            "items": [
339                {
340                    "itemId": "item01",
341                    "title": "What is your name?",
342                    "description": "",
343                    "questionItem": {
344                        "question": {
345                            "questionId": "q01",
346                            "required": true,
347                            "textQuestion": {
348                                "paragraph": false
349                            }
350                        }
351                    }
352                }
353            ],
354            "responderUri": "https://docs.google.com/forms/d/e/1FAIpQLSe_abc123/viewform",
355            "revisionId": "rev001"
356        }"#;
357
358        let form: Form = serde_json::from_str(json).expect("deserialize form");
359        assert_eq!(form.form_id, "1FAIpQLSe_abc123");
360        assert_eq!(form.info.title, "Customer Survey");
361        assert_eq!(form.info.description, "Please fill in your feedback.");
362        assert_eq!(form.info.document_title, "Customer Survey (Responses)");
363        assert_eq!(form.items.len(), 1);
364        assert_eq!(form.items[0].item_id, "item01");
365        assert_eq!(form.items[0].title, "What is your name?");
366        assert!(form.items[0].question_item.is_some());
367        assert_eq!(form.responder_uri, "https://docs.google.com/forms/d/e/1FAIpQLSe_abc123/viewform");
368        assert_eq!(form.revision_id, "rev001");
369        assert!(form.linked_sheet_id.is_none());
370    }
371
372    // -----------------------------------------------------------------------
373    // test_form_title
374    // -----------------------------------------------------------------------
375
376    #[test]
377    fn test_form_title() {
378        let form = Form {
379            form_id: "f1".to_string(),
380            info: FormInfo {
381                title: "My Survey".to_string(),
382                description: String::new(),
383                document_title: String::new(),
384            },
385            items: vec![],
386            responder_uri: String::new(),
387            revision_id: String::new(),
388            linked_sheet_id: None,
389        };
390        assert_eq!(form.title(), "My Survey");
391    }
392
393    // -----------------------------------------------------------------------
394    // test_form_title_missing
395    // -----------------------------------------------------------------------
396
397    #[test]
398    fn test_form_title_missing() {
399        let form = Form {
400            form_id: "f2".to_string(),
401            info: FormInfo::default(),
402            items: vec![],
403            responder_uri: String::new(),
404            revision_id: String::new(),
405            linked_sheet_id: None,
406        };
407        assert_eq!(form.title(), "(Untitled)");
408    }
409
410    // -----------------------------------------------------------------------
411    // test_form_question_count
412    // -----------------------------------------------------------------------
413
414    #[test]
415    fn test_form_question_count() {
416        let question_item = Item {
417            item_id: "i1".to_string(),
418            title: "Q1".to_string(),
419            description: String::new(),
420            question_item: Some(QuestionItem {
421                question: Question {
422                    question_id: "q1".to_string(),
423                    required: false,
424                    choice_question: None,
425                    text_question: Some(TextQuestion { paragraph: false }),
426                    scale_question: None,
427                },
428            }),
429            page_break_item: None,
430        };
431
432        let page_break = Item {
433            item_id: "i2".to_string(),
434            title: "Section 2".to_string(),
435            description: String::new(),
436            question_item: None,
437            page_break_item: Some(serde_json::json!({})),
438        };
439
440        let form = Form {
441            form_id: "f3".to_string(),
442            info: FormInfo::default(),
443            items: vec![question_item, page_break],
444            responder_uri: String::new(),
445            revision_id: String::new(),
446            linked_sheet_id: None,
447        };
448
449        assert_eq!(form.question_count(), 1);
450    }
451
452    // -----------------------------------------------------------------------
453    // test_item_is_question
454    // -----------------------------------------------------------------------
455
456    #[test]
457    fn test_item_is_question() {
458        let question_item = Item {
459            item_id: "i1".to_string(),
460            title: "Q?".to_string(),
461            description: String::new(),
462            question_item: Some(QuestionItem {
463                question: Question {
464                    question_id: "q1".to_string(),
465                    required: false,
466                    choice_question: None,
467                    text_question: Some(TextQuestion { paragraph: false }),
468                    scale_question: None,
469                },
470            }),
471            page_break_item: None,
472        };
473        assert!(question_item.is_question());
474
475        let page_break = Item {
476            item_id: "i2".to_string(),
477            title: "Break".to_string(),
478            description: String::new(),
479            question_item: None,
480            page_break_item: Some(serde_json::json!({})),
481        };
482        assert!(!page_break.is_question());
483    }
484
485    // -----------------------------------------------------------------------
486    // test_question_type_choice
487    // -----------------------------------------------------------------------
488
489    #[test]
490    fn test_question_type_choice() {
491        let q = Question {
492            question_id: "q1".to_string(),
493            required: false,
494            choice_question: Some(ChoiceQuestion {
495                type_: "RADIO".to_string(),
496                options: vec![],
497            }),
498            text_question: None,
499            scale_question: None,
500        };
501        assert_eq!(q.question_type(), "choice");
502    }
503
504    // -----------------------------------------------------------------------
505    // test_question_type_text
506    // -----------------------------------------------------------------------
507
508    #[test]
509    fn test_question_type_text() {
510        let q = Question {
511            question_id: "q2".to_string(),
512            required: true,
513            choice_question: None,
514            text_question: Some(TextQuestion { paragraph: true }),
515            scale_question: None,
516        };
517        assert_eq!(q.question_type(), "text");
518    }
519
520    // -----------------------------------------------------------------------
521    // test_question_type_scale
522    // -----------------------------------------------------------------------
523
524    #[test]
525    fn test_question_type_scale() {
526        let q = Question {
527            question_id: "q3".to_string(),
528            required: false,
529            choice_question: None,
530            text_question: None,
531            scale_question: Some(ScaleQuestion {
532                low: 1,
533                high: 5,
534                low_label: Some("Poor".to_string()),
535                high_label: Some("Excellent".to_string()),
536            }),
537        };
538        assert_eq!(q.question_type(), "scale");
539    }
540
541    // -----------------------------------------------------------------------
542    // test_form_response_deserialize
543    // -----------------------------------------------------------------------
544
545    #[test]
546    fn test_form_response_deserialize() {
547        let json = r#"{
548            "responseId": "resp_abc",
549            "createTime": "2024-03-01T10:00:00Z",
550            "lastSubmittedTime": "2024-03-01T10:05:00Z",
551            "respondentEmail": "alice@example.com",
552            "answers": {
553                "q01": {
554                    "questionId": "q01",
555                    "textAnswers": {
556                        "answers": [
557                            {"value": "Alice"}
558                        ]
559                    }
560                }
561            }
562        }"#;
563
564        let resp: FormResponse = serde_json::from_str(json).expect("deserialize form response");
565        assert_eq!(resp.response_id, "resp_abc");
566        assert_eq!(resp.create_time, "2024-03-01T10:00:00Z");
567        assert_eq!(resp.last_submitted_time, "2024-03-01T10:05:00Z");
568        assert_eq!(resp.respondent_email.as_deref(), Some("alice@example.com"));
569        assert_eq!(resp.answers.len(), 1);
570
571        let answer = resp.answers.get("q01").expect("answer for q01");
572        assert_eq!(answer.question_id, "q01");
573        let text_answers = answer.text_answers.as_ref().expect("text_answers present");
574        assert_eq!(text_answers.answers.len(), 1);
575        assert_eq!(text_answers.answers[0].value, "Alice");
576    }
577
578    // -----------------------------------------------------------------------
579    // test_response_list_deserialize
580    // -----------------------------------------------------------------------
581
582    #[test]
583    fn test_response_list_deserialize() {
584        let json = r#"{
585            "responses": [
586                {
587                    "responseId": "r1",
588                    "createTime": "2024-03-01T09:00:00Z",
589                    "lastSubmittedTime": "2024-03-01T09:01:00Z",
590                    "answers": {}
591                },
592                {
593                    "responseId": "r2",
594                    "createTime": "2024-03-01T10:00:00Z",
595                    "lastSubmittedTime": "2024-03-01T10:02:00Z",
596                    "answers": {}
597                }
598            ],
599            "nextPageToken": "token_xyz"
600        }"#;
601
602        let list: ResponseList = serde_json::from_str(json).expect("deserialize response list");
603        assert_eq!(list.responses.len(), 2);
604        assert_eq!(list.responses[0].response_id, "r1");
605        assert_eq!(list.responses[1].response_id, "r2");
606        assert_eq!(list.next_page_token.as_deref(), Some("token_xyz"));
607    }
608
609    // -----------------------------------------------------------------------
610    // test_choice_question_options
611    // -----------------------------------------------------------------------
612
613    #[test]
614    fn test_choice_question_options() {
615        let json = r#"{
616            "type": "CHECKBOX",
617            "options": [
618                {"value": "Option A"},
619                {"value": "Option B"},
620                {"value": "Option C"}
621            ]
622        }"#;
623
624        let cq: ChoiceQuestion = serde_json::from_str(json).expect("deserialize choice question");
625        assert_eq!(cq.type_, "CHECKBOX");
626        assert_eq!(cq.options.len(), 3);
627        assert_eq!(cq.options[0].value, "Option A");
628        assert_eq!(cq.options[1].value, "Option B");
629        assert_eq!(cq.options[2].value, "Option C");
630    }
631
632    // -----------------------------------------------------------------------
633    // FormsError display
634    // -----------------------------------------------------------------------
635
636    #[test]
637    fn test_forms_error_api_display() {
638        let err = FormsError::Api { status: 403, message: "Forbidden".to_string() };
639        let msg = err.to_string();
640        assert!(msg.contains("403"), "got: {msg}");
641        assert!(msg.contains("Forbidden"), "got: {msg}");
642    }
643
644    #[test]
645    fn test_forms_error_json_display() {
646        let inner: serde_json::Error = serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
647        let err = FormsError::Json(inner);
648        let msg = err.to_string();
649        assert!(!msg.is_empty(), "error message should be non-empty");
650    }
651}