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) fn bounded(
242 count: usize,
243 min: usize,
244 max: usize,
245 field: &str,
246 what: &str,
247) -> Result<(), Refusal> {
248 if count < min {
249 Err(Refusal::over_limit(
250 field,
251 min as u64,
252 format!("{count} {what}; at least {min}"),
253 ))
254 } else if count > max {
255 Err(Refusal::over_limit(
256 field,
257 max as u64,
258 format!("{count} {what}; at most {max}"),
259 ))
260 } else {
261 Ok(())
262 }
263}
264
265pub mod content_types {
269 pub const PNG: &str = "image/png";
270 pub const JPEG: &str = "image/jpeg";
271 pub const WEBP: &str = "image/webp";
272 pub const ALL: &[&str] = &[PNG, JPEG, WEBP];
273}
274
275pub const IMAGE_MAX_BYTES: usize = 4 * 1024 * 1024;
277pub const IMAGES_MAX_TOTAL_BYTES: usize = 8 * 1024 * 1024;
279pub const IMAGE_MAX_PIXELS: u64 = 16_000_000;
282
283#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
285pub struct InlineImage {
286 pub content_type: String,
288 pub base64: String,
289}
290
291impl InlineImage {
292 pub fn new(content_type: impl Into<String>, base64: impl Into<String>) -> Self {
293 Self {
294 content_type: content_type.into(),
295 base64: base64.into(),
296 }
297 }
298}
299
300#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
303#[serde(untagged)]
304pub enum Image {
305 DataUrl(String),
306 Inline(InlineImage),
307}
308
309impl Image {
310 pub fn approx_bytes(&self) -> usize {
312 let payload = match self {
313 Self::DataUrl(url) => url.split_once(',').map_or("", |(_, data)| data),
314 Self::Inline(inline) => &inline.base64,
315 };
316 payload.trim_end_matches('=').len() * 3 / 4
317 }
318}
319
320pub fn check_image(image: &Image) -> Result<(), String> {
322 match image {
323 Image::DataUrl(url) => {
324 let Some(rest) = url.strip_prefix("data:") else {
325 return Err("an image string must be a data: URL; remote URLs are refused".into());
326 };
327 let Some((media, _)) = rest.split_once(',') else {
328 return Err("a data: URL has no payload".into());
329 };
330 let content_type = media.split(';').next().unwrap_or("");
331 if !content_types::ALL.contains(&content_type) {
332 return Err(format!("unsupported image content type {content_type}"));
333 }
334 }
335 Image::Inline(inline) => {
336 if !content_types::ALL.contains(&inline.content_type.as_str()) {
337 return Err(format!(
338 "unsupported image content type {}",
339 inline.content_type
340 ));
341 }
342 }
343 }
344 if image.approx_bytes() > IMAGE_MAX_BYTES {
345 return Err(format!("an image is at most {IMAGE_MAX_BYTES} bytes"));
346 }
347 Ok(())
348}
349
350#[cfg(test)]
351mod tests {
352 use serde_json::json;
353
354 use super::*;
355 use crate::errors::INVALID_PARAMS;
356
357 fn options(n: usize) -> IndexMap<String, Value> {
358 (0..n).map(|i| (format!("o{i}"), json!("x"))).collect()
359 }
360
361 fn levels(n: usize) -> Vec<Value> {
362 (0..n).map(|i| json!(format!("level {i}"))).collect()
363 }
364
365 #[test]
366 fn choice_options_and_score_levels_are_bounded() {
367 assert!(check_question("q", &Question::choice("i", options(2))).is_ok());
368 assert!(check_question("q", &Question::choice("i", options(255))).is_ok());
369 for n in [1, 256] {
370 let refusal = check_question("q", &Question::choice("i", options(n))).unwrap_err();
371 assert_eq!(refusal.code, INVALID_PARAMS);
372 assert_eq!(refusal.field(), Some("questions.q.criteria"));
373 }
374 assert!(check_question("q", &Question::score("i", levels(2))).is_ok());
375 assert!(check_question("q", &Question::score("i", levels(10))).is_ok());
376 for (n, limit) in [(1, 2), (11, 10)] {
377 let refusal = check_question("q", &Question::score("i", levels(n))).unwrap_err();
378 assert_eq!(refusal.field(), Some("questions.q.criteria"));
379 assert_eq!(refusal.detail.limit, Some(limit));
380 }
381 }
382
383 #[test]
384 fn ids_types_and_instructions_are_refused_by_field() {
385 let refusal = check_question("Bad", &Question::noul("i")).unwrap_err();
386 assert_eq!(refusal.field(), Some("questions.Bad"));
387 let refusal = check_question("q", &Question::noul("")).unwrap_err();
388 assert_eq!(refusal.field(), Some("questions.q.instructions"));
389 let refusal =
390 check_question("q", &Question::new(QuestionType::parse("rank"), "i")).unwrap_err();
391 assert_eq!(refusal.field(), Some("questions.q.type"));
392 let refusal =
395 check_question("q", &Question::new(QuestionType::parse("rank"), "")).unwrap_err();
396 assert_eq!(refusal.field(), Some("questions.q.type"));
397 assert!(check_question("q", &Question::noul(json!({"ask": "is it?"}))).is_ok());
398 assert!(is_question_id(&"a".repeat(64)));
399 assert!(!is_question_id(&"a".repeat(65)));
400 }
401
402 #[test]
403 fn noul_criteria_is_true_and_false() {
404 let good: IndexMap<String, Value> = [
405 ("true".to_owned(), json!("yes")),
406 ("false".to_owned(), json!("no")),
407 ]
408 .into();
409 let question = Question::noul("i").with_criteria(Criteria::Options(good));
410 assert!(check_question("q", &question).is_ok());
411 let bad: IndexMap<String, Value> = [("yes".to_owned(), json!("y"))].into();
412 let question = Question::noul("i").with_criteria(Criteria::Options(bad));
413 assert_eq!(
414 check_question("q", &question).unwrap_err().field(),
415 Some("questions.q.criteria")
416 );
417 }
418
419 #[test]
420 fn question_type_decodes_open() {
421 let question: Question =
422 serde_json::from_str(r#"{"type":"rank","instructions":"i"}"#).unwrap();
423 assert_eq!(question.kind, QuestionType::Other("rank".into()));
424 assert_eq!(
425 serde_json::to_string(&question).unwrap(),
426 r#"{"type":"rank","instructions":"i"}"#
427 );
428 }
429
430 #[test]
431 fn remote_urls_and_unknown_types_are_refused() {
432 assert!(check_image(&Image::DataUrl("https://example.com/a.png".into())).is_err());
433 assert!(check_image(&Image::DataUrl("data:image/gif;base64,AAAA".into())).is_err());
434 assert!(check_image(&Image::DataUrl("data:image/png;base64,AAAA".into())).is_ok());
435 assert!(check_image(&Image::Inline(InlineImage::new("image/webp", "AAAA"))).is_ok());
436 assert!(check_image(&Image::Inline(InlineImage::new("image/bmp", "AAAA"))).is_err());
437 }
438}