Skip to main content

typesafe_system_one/
request.rs

1//! [`SystemOneRequest`] builder.
2
3use std::collections::BTreeMap;
4
5use serde_json::Value;
6
7use crate::errors::Error;
8use crate::questions::{validate, Question};
9
10/// A request to answer questions about some content.
11///
12/// The `state` is the content to evaluate — anything that serializes to a
13/// JSON string, object, or array (numbers, booleans, and `null` are rejected
14/// by client-side validation). Questions are keyed by caller-chosen IDs that
15/// the response uses to key its answers.
16///
17/// ```
18/// # use typesafe_system_one::{Noul, Score, SystemOneRequest};
19/// let request = SystemOneRequest::new("Please help.")
20///     .model("jev-1.13.0")
21///     .question("billing", Noul::new("Is this about billing?"))
22///     .question(
23///         "urgency",
24///         Score::new("How urgent?", ["Can wait", "Needs attention today"]),
25///     );
26/// ```
27#[derive(Debug, Clone, PartialEq)]
28pub struct SystemOneRequest {
29    /// The content to evaluate: JSON string, object, or array.
30    pub state: Value,
31    /// Model name or alias; `None` uses the client's default model.
32    pub model: Option<String>,
33    /// Questions keyed by caller-chosen IDs.
34    pub questions: BTreeMap<String, Question>,
35}
36
37impl SystemOneRequest {
38    /// Creates a request from state that is already a JSON value.
39    ///
40    /// The state must serialize to a JSON string, object, or array;
41    /// numbers, booleans, and `null` are rejected by
42    /// [`Client::system_one`](crate::Client::system_one) before any network
43    /// I/O.
44    pub fn new(state: impl Into<Value>) -> Self {
45        Self {
46            state: state.into(),
47            model: None,
48            questions: BTreeMap::new(),
49        }
50    }
51
52    /// Creates a request by serializing any `Serialize` value as the state.
53    ///
54    /// The serialized state must be a JSON string, object, or array.
55    pub fn with_state_serialize<T: serde::Serialize>(state: &T) -> Result<Self, Error> {
56        let value = serde_json::to_value(state).map_err(|error| {
57            Error::InvalidRequest(format!("state failed to serialize: {error}"))
58        })?;
59        if !matches!(value, Value::String(_) | Value::Object(_) | Value::Array(_)) {
60            return Err(Error::InvalidRequest(
61                "state must serialize to a JSON string, object, or array.".into(),
62            ));
63        }
64        Ok(Self::new(value))
65    }
66
67    /// Overrides the model for this request.
68    pub fn model(mut self, model: impl Into<String>) -> Self {
69        self.model = Some(model.into());
70        self
71    }
72
73    /// Adds a question under a caller-chosen ID.
74    pub fn question(mut self, id: impl Into<String>, question: impl Into<Question>) -> Self {
75        self.questions.insert(id.into(), question.into());
76        self
77    }
78
79    /// Adds every question in an iterable of `(id, question)` pairs.
80    pub fn questions<I, S>(mut self, questions: I) -> Self
81    where
82        I: IntoIterator<Item = (S, Question)>,
83        S: Into<String>,
84    {
85        for (id, question) in questions {
86            self.questions.insert(id.into(), question);
87        }
88        self
89    }
90
91    /// Validates the request client-side, before any network I/O.
92    pub(crate) fn validate(&self) -> Result<(), Error> {
93        validate(&self.state, &self.questions)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::questions::{Choice, Noul, Score};
101    use serde_json::json;
102
103    #[derive(serde::Serialize)]
104    struct Ticket {
105        subject: String,
106        body: String,
107    }
108
109    #[test]
110    fn with_state_serialize_accepts_objects() {
111        let ticket = Ticket {
112            subject: "Refund".into(),
113            body: "Charged twice".into(),
114        };
115        let request = SystemOneRequest::with_state_serialize(&ticket).unwrap();
116        assert_eq!(
117            request.state,
118            json!({"subject": "Refund", "body": "Charged twice"})
119        );
120    }
121
122    #[test]
123    fn with_state_serialize_rejects_primitives() {
124        let err = SystemOneRequest::with_state_serialize(&42).unwrap_err();
125        assert!(matches!(err, Error::InvalidRequest(_)));
126        let err = SystemOneRequest::with_state_serialize(&true).unwrap_err();
127        assert!(matches!(err, Error::InvalidRequest(_)));
128        let err = SystemOneRequest::with_state_serialize::<Option<u8>>(&None).unwrap_err();
129        assert!(matches!(err, Error::InvalidRequest(_)));
130        let err = SystemOneRequest::with_state_serialize(&"string is fine").unwrap();
131        assert_eq!(err.state, json!("string is fine"));
132    }
133
134    #[test]
135    fn builder_methods() {
136        let request = SystemOneRequest::new("text")
137            .model("jev-1.13.0")
138            .question("a", Noul::new("q1"))
139            .question("b", Choice::new("q2").option("x", "y"))
140            .question("c", Score::new("q3", ["1", "2"]));
141        assert_eq!(request.model.as_deref(), Some("jev-1.13.0"));
142        assert_eq!(request.questions.len(), 3);
143        assert_eq!(request.questions["a"], Question::Noul(Noul::new("q1")));
144        assert!(request.validate().is_ok());
145    }
146}