1use std::fmt;
9
10use indexmap::IndexMap;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use serde_json::Value;
13
14use crate::errors::Refusal;
15
16pub mod types {
18 pub const NOUL: &str = "noul";
21 pub const CHOICE: &str = "choice";
23 pub const SCORE: &str = "score";
25}
26
27pub const CHOICE_MIN_OPTIONS: usize = 2;
29pub const CHOICE_MAX_OPTIONS: usize = 255;
31pub const SCORE_MIN_LEVELS: usize = 2;
33pub const SCORE_MAX_LEVELS: usize = 10;
35pub const QUESTION_ID_MAX_LEN: usize = 64;
37pub const NOUL_CRITERIA_KEYS: [&str; 2] = ["true", "false"];
39
40#[derive(Clone, Debug, PartialEq, Eq, Hash)]
47pub enum QuestionType {
48 Noul,
49 Choice,
50 Score,
51 Other(String),
52}
53
54impl QuestionType {
55 pub fn parse(name: &str) -> Self {
56 match name {
57 types::NOUL => Self::Noul,
58 types::CHOICE => Self::Choice,
59 types::SCORE => Self::Score,
60 other => Self::Other(other.to_owned()),
61 }
62 }
63
64 pub fn as_str(&self) -> &str {
65 match self {
66 Self::Noul => types::NOUL,
67 Self::Choice => types::CHOICE,
68 Self::Score => types::SCORE,
69 Self::Other(other) => other,
70 }
71 }
72}
73
74impl fmt::Display for QuestionType {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str(self.as_str())
77 }
78}
79
80impl Serialize for QuestionType {
81 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
82 serializer.serialize_str(self.as_str())
83 }
84}
85
86impl<'de> Deserialize<'de> for QuestionType {
87 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
88 Ok(Self::parse(&String::deserialize(deserializer)?))
89 }
90}
91
92#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
104#[serde(untagged)]
105pub enum Criteria {
106 Levels(Vec<Value>),
107 Options(IndexMap<String, Value>),
108}
109
110#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
116#[non_exhaustive]
117pub struct Question {
118 #[serde(rename = "type")]
119 pub kind: QuestionType,
120 pub instructions: Value,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub criteria: Option<Criteria>,
124}
125
126impl Question {
127 pub fn new(kind: QuestionType, instructions: impl Into<Value>) -> Self {
128 Self {
129 kind,
130 instructions: instructions.into(),
131 criteria: None,
132 }
133 }
134
135 pub fn noul(instructions: impl Into<Value>) -> Self {
137 Self::new(QuestionType::Noul, instructions)
138 }
139
140 pub fn choice(instructions: impl Into<Value>, options: IndexMap<String, Value>) -> Self {
142 Self::new(QuestionType::Choice, instructions).with_criteria(Criteria::Options(options))
143 }
144
145 pub fn score(instructions: impl Into<Value>, levels: Vec<Value>) -> Self {
147 Self::new(QuestionType::Score, instructions).with_criteria(Criteria::Levels(levels))
148 }
149
150 pub fn with_criteria(mut self, criteria: Criteria) -> Self {
151 self.criteria = Some(criteria);
152 self
153 }
154}
155
156pub fn is_question_id(id: &str) -> bool {
159 !id.is_empty()
160 && id.len() <= QUESTION_ID_MAX_LEN
161 && id
162 .bytes()
163 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
164}
165
166pub fn check_question(id: &str, question: &Question) -> Result<(), Refusal> {
172 let at = |member: &str| format!("questions.{id}.{member}");
173 if !is_question_id(id) {
174 return Err(Refusal::invalid_params(
175 format!("questions.{id}"),
176 "a question id is 1-64 characters of [a-z0-9_-]",
177 ));
178 }
179 let unknown_type = |name: &str| {
180 Refusal::invalid_params(
181 at("type"),
182 format!("unknown question type {name}; expected noul, choice or score"),
183 )
184 };
185 if let QuestionType::Other(name) = &question.kind {
186 return Err(unknown_type(name));
187 }
188 let instructions_ok = match &question.instructions {
189 Value::String(text) => !text.is_empty(),
190 Value::Object(_) | Value::Array(_) => true,
191 _ => false,
192 };
193 if !instructions_ok {
194 return Err(Refusal::invalid_params(
195 at("instructions"),
196 "instructions is a non-empty string, an object or an array",
197 ));
198 }
199 match (&question.kind, &question.criteria) {
200 (QuestionType::Noul, None) => Ok(()),
201 (QuestionType::Noul, Some(Criteria::Options(options)))
202 if options.len() == NOUL_CRITERIA_KEYS.len()
203 && NOUL_CRITERIA_KEYS
204 .iter()
205 .all(|key| options.get(*key).is_some_and(Value::is_string)) =>
206 {
207 Ok(())
208 }
209 (QuestionType::Noul, Some(_)) => Err(Refusal::invalid_params(
210 at("criteria"),
211 "noul criteria, when present, is {\"true\": string, \"false\": string}",
212 )),
213 (QuestionType::Choice, Some(Criteria::Options(options))) => bounded(
214 options.len(),
215 CHOICE_MIN_OPTIONS,
216 CHOICE_MAX_OPTIONS,
217 &at("criteria"),
218 "choice options",
219 ),
220 (QuestionType::Choice, _) => Err(Refusal::invalid_params(
221 at("criteria"),
222 "choice criteria is an object from option id to description",
223 )),
224 (QuestionType::Score, Some(Criteria::Levels(levels))) => bounded(
225 levels.len(),
226 SCORE_MIN_LEVELS,
227 SCORE_MAX_LEVELS,
228 &at("criteria"),
229 "score levels",
230 ),
231 (QuestionType::Score, _) => Err(Refusal::invalid_params(
232 at("criteria"),
233 "score criteria is an array of level descriptions, lowest first",
234 )),
235 (QuestionType::Other(name), _) => Err(unknown_type(name)),
236 }
237}
238
239pub(crate) use cortexkit_model_batch::admission::bounded;
240
241pub use cortexkit_model_batch::image::{
249 check_image, content_types, Image, InlineImage, IMAGES_MAX_TOTAL_BYTES, IMAGE_MAX_BYTES,
250 IMAGE_MAX_PIXELS,
251};
252
253#[cfg(test)]
254mod tests {
255 use serde_json::json;
256
257 use super::*;
258 use crate::errors::INVALID_PARAMS;
259
260 fn options(n: usize) -> IndexMap<String, Value> {
261 (0..n).map(|i| (format!("o{i}"), json!("x"))).collect()
262 }
263
264 fn levels(n: usize) -> Vec<Value> {
265 (0..n).map(|i| json!(format!("level {i}"))).collect()
266 }
267
268 #[test]
269 fn choice_options_and_score_levels_are_bounded() {
270 assert!(check_question("q", &Question::choice("i", options(2))).is_ok());
271 assert!(check_question("q", &Question::choice("i", options(255))).is_ok());
272 for n in [1, 256] {
273 let refusal = check_question("q", &Question::choice("i", options(n))).unwrap_err();
274 assert_eq!(refusal.code, INVALID_PARAMS);
275 assert_eq!(refusal.field(), Some("questions.q.criteria"));
276 }
277 assert!(check_question("q", &Question::score("i", levels(2))).is_ok());
278 assert!(check_question("q", &Question::score("i", levels(10))).is_ok());
279 for (n, limit) in [(1, 2), (11, 10)] {
280 let refusal = check_question("q", &Question::score("i", levels(n))).unwrap_err();
281 assert_eq!(refusal.field(), Some("questions.q.criteria"));
282 assert_eq!(refusal.detail.limit, Some(limit));
283 }
284 }
285
286 #[test]
287 fn ids_types_and_instructions_are_refused_by_field() {
288 let refusal = check_question("Bad", &Question::noul("i")).unwrap_err();
289 assert_eq!(refusal.field(), Some("questions.Bad"));
290 let refusal = check_question("q", &Question::noul("")).unwrap_err();
291 assert_eq!(refusal.field(), Some("questions.q.instructions"));
292 let refusal =
293 check_question("q", &Question::new(QuestionType::parse("rank"), "i")).unwrap_err();
294 assert_eq!(refusal.field(), Some("questions.q.type"));
295 let refusal =
298 check_question("q", &Question::new(QuestionType::parse("rank"), "")).unwrap_err();
299 assert_eq!(refusal.field(), Some("questions.q.type"));
300 assert!(check_question("q", &Question::noul(json!({"ask": "is it?"}))).is_ok());
301 assert!(is_question_id(&"a".repeat(64)));
302 assert!(!is_question_id(&"a".repeat(65)));
303 }
304
305 #[test]
306 fn noul_criteria_is_true_and_false() {
307 let good: IndexMap<String, Value> = [
308 ("true".to_owned(), json!("yes")),
309 ("false".to_owned(), json!("no")),
310 ]
311 .into();
312 let question = Question::noul("i").with_criteria(Criteria::Options(good));
313 assert!(check_question("q", &question).is_ok());
314 let bad: IndexMap<String, Value> = [("yes".to_owned(), json!("y"))].into();
315 let question = Question::noul("i").with_criteria(Criteria::Options(bad));
316 assert_eq!(
317 check_question("q", &question).unwrap_err().field(),
318 Some("questions.q.criteria")
319 );
320 }
321
322 #[test]
323 fn question_type_decodes_open() {
324 let question: Question =
325 serde_json::from_str(r#"{"type":"rank","instructions":"i"}"#).unwrap();
326 assert_eq!(question.kind, QuestionType::Other("rank".into()));
327 assert_eq!(
328 serde_json::to_string(&question).unwrap(),
329 r#"{"type":"rank","instructions":"i"}"#
330 );
331 }
332
333 #[test]
334 fn remote_urls_and_unknown_types_are_refused() {
335 assert!(check_image(&Image::DataUrl("https://example.com/a.png".into())).is_err());
336 assert!(check_image(&Image::DataUrl("data:image/gif;base64,AAAA".into())).is_err());
337 assert!(check_image(&Image::DataUrl("data:image/png;base64,AAAA".into())).is_ok());
338 assert!(check_image(&Image::Inline(InlineImage::new("image/webp", "AAAA"))).is_ok());
339 assert!(check_image(&Image::Inline(InlineImage::new("image/bmp", "AAAA"))).is_err());
340 assert!(check_image(&Image::Inline(InlineImage::new("image/png", "A A="))).is_err());
341 assert!(check_image(&Image::DataUrl("data:image/png,AAAA".into())).is_err());
342 }
343}