use std::fmt;
use indexmap::IndexMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use crate::errors::Refusal;
pub mod types {
pub const NOUL: &str = "noul";
pub const CHOICE: &str = "choice";
pub const SCORE: &str = "score";
}
pub const CHOICE_MIN_OPTIONS: usize = 2;
pub const CHOICE_MAX_OPTIONS: usize = 255;
pub const SCORE_MIN_LEVELS: usize = 2;
pub const SCORE_MAX_LEVELS: usize = 10;
pub const QUESTION_ID_MAX_LEN: usize = 64;
pub const NOUL_CRITERIA_KEYS: [&str; 2] = ["true", "false"];
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum QuestionType {
Noul,
Choice,
Score,
Other(String),
}
impl QuestionType {
pub fn parse(name: &str) -> Self {
match name {
types::NOUL => Self::Noul,
types::CHOICE => Self::Choice,
types::SCORE => Self::Score,
other => Self::Other(other.to_owned()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Noul => types::NOUL,
Self::Choice => types::CHOICE,
Self::Score => types::SCORE,
Self::Other(other) => other,
}
}
}
impl fmt::Display for QuestionType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for QuestionType {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for QuestionType {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Self::parse(&String::deserialize(deserializer)?))
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum Criteria {
Levels(Vec<Value>),
Options(IndexMap<String, Value>),
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Question {
#[serde(rename = "type")]
pub kind: QuestionType,
pub instructions: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub criteria: Option<Criteria>,
}
impl Question {
pub fn new(kind: QuestionType, instructions: impl Into<Value>) -> Self {
Self {
kind,
instructions: instructions.into(),
criteria: None,
}
}
pub fn noul(instructions: impl Into<Value>) -> Self {
Self::new(QuestionType::Noul, instructions)
}
pub fn choice(instructions: impl Into<Value>, options: IndexMap<String, Value>) -> Self {
Self::new(QuestionType::Choice, instructions).with_criteria(Criteria::Options(options))
}
pub fn score(instructions: impl Into<Value>, levels: Vec<Value>) -> Self {
Self::new(QuestionType::Score, instructions).with_criteria(Criteria::Levels(levels))
}
pub fn with_criteria(mut self, criteria: Criteria) -> Self {
self.criteria = Some(criteria);
self
}
}
pub fn is_question_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= QUESTION_ID_MAX_LEN
&& id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
pub fn check_question(id: &str, question: &Question) -> Result<(), Refusal> {
let at = |member: &str| format!("questions.{id}.{member}");
if !is_question_id(id) {
return Err(Refusal::invalid_params(
format!("questions.{id}"),
"a question id is 1-64 characters of [a-z0-9_-]",
));
}
let unknown_type = |name: &str| {
Refusal::invalid_params(
at("type"),
format!("unknown question type {name}; expected noul, choice or score"),
)
};
if let QuestionType::Other(name) = &question.kind {
return Err(unknown_type(name));
}
let instructions_ok = match &question.instructions {
Value::String(text) => !text.is_empty(),
Value::Object(_) | Value::Array(_) => true,
_ => false,
};
if !instructions_ok {
return Err(Refusal::invalid_params(
at("instructions"),
"instructions is a non-empty string, an object or an array",
));
}
match (&question.kind, &question.criteria) {
(QuestionType::Noul, None) => Ok(()),
(QuestionType::Noul, Some(Criteria::Options(options)))
if options.len() == NOUL_CRITERIA_KEYS.len()
&& NOUL_CRITERIA_KEYS
.iter()
.all(|key| options.get(*key).is_some_and(Value::is_string)) =>
{
Ok(())
}
(QuestionType::Noul, Some(_)) => Err(Refusal::invalid_params(
at("criteria"),
"noul criteria, when present, is {\"true\": string, \"false\": string}",
)),
(QuestionType::Choice, Some(Criteria::Options(options))) => bounded(
options.len(),
CHOICE_MIN_OPTIONS,
CHOICE_MAX_OPTIONS,
&at("criteria"),
"choice options",
),
(QuestionType::Choice, _) => Err(Refusal::invalid_params(
at("criteria"),
"choice criteria is an object from option id to description",
)),
(QuestionType::Score, Some(Criteria::Levels(levels))) => bounded(
levels.len(),
SCORE_MIN_LEVELS,
SCORE_MAX_LEVELS,
&at("criteria"),
"score levels",
),
(QuestionType::Score, _) => Err(Refusal::invalid_params(
at("criteria"),
"score criteria is an array of level descriptions, lowest first",
)),
(QuestionType::Other(name), _) => Err(unknown_type(name)),
}
}
pub(crate) use cortexkit_model_batch::admission::bounded;
pub use cortexkit_model_batch::image::{
check_image, content_types, Image, InlineImage, IMAGES_MAX_TOTAL_BYTES, IMAGE_MAX_BYTES,
IMAGE_MAX_PIXELS,
};
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::errors::INVALID_PARAMS;
fn options(n: usize) -> IndexMap<String, Value> {
(0..n).map(|i| (format!("o{i}"), json!("x"))).collect()
}
fn levels(n: usize) -> Vec<Value> {
(0..n).map(|i| json!(format!("level {i}"))).collect()
}
#[test]
fn choice_options_and_score_levels_are_bounded() {
assert!(check_question("q", &Question::choice("i", options(2))).is_ok());
assert!(check_question("q", &Question::choice("i", options(255))).is_ok());
for n in [1, 256] {
let refusal = check_question("q", &Question::choice("i", options(n))).unwrap_err();
assert_eq!(refusal.code, INVALID_PARAMS);
assert_eq!(refusal.field(), Some("questions.q.criteria"));
}
assert!(check_question("q", &Question::score("i", levels(2))).is_ok());
assert!(check_question("q", &Question::score("i", levels(10))).is_ok());
for (n, limit) in [(1, 2), (11, 10)] {
let refusal = check_question("q", &Question::score("i", levels(n))).unwrap_err();
assert_eq!(refusal.field(), Some("questions.q.criteria"));
assert_eq!(refusal.detail.limit, Some(limit));
}
}
#[test]
fn ids_types_and_instructions_are_refused_by_field() {
let refusal = check_question("Bad", &Question::noul("i")).unwrap_err();
assert_eq!(refusal.field(), Some("questions.Bad"));
let refusal = check_question("q", &Question::noul("")).unwrap_err();
assert_eq!(refusal.field(), Some("questions.q.instructions"));
let refusal =
check_question("q", &Question::new(QuestionType::parse("rank"), "i")).unwrap_err();
assert_eq!(refusal.field(), Some("questions.q.type"));
let refusal =
check_question("q", &Question::new(QuestionType::parse("rank"), "")).unwrap_err();
assert_eq!(refusal.field(), Some("questions.q.type"));
assert!(check_question("q", &Question::noul(json!({"ask": "is it?"}))).is_ok());
assert!(is_question_id(&"a".repeat(64)));
assert!(!is_question_id(&"a".repeat(65)));
}
#[test]
fn noul_criteria_is_true_and_false() {
let good: IndexMap<String, Value> = [
("true".to_owned(), json!("yes")),
("false".to_owned(), json!("no")),
]
.into();
let question = Question::noul("i").with_criteria(Criteria::Options(good));
assert!(check_question("q", &question).is_ok());
let bad: IndexMap<String, Value> = [("yes".to_owned(), json!("y"))].into();
let question = Question::noul("i").with_criteria(Criteria::Options(bad));
assert_eq!(
check_question("q", &question).unwrap_err().field(),
Some("questions.q.criteria")
);
}
#[test]
fn question_type_decodes_open() {
let question: Question =
serde_json::from_str(r#"{"type":"rank","instructions":"i"}"#).unwrap();
assert_eq!(question.kind, QuestionType::Other("rank".into()));
assert_eq!(
serde_json::to_string(&question).unwrap(),
r#"{"type":"rank","instructions":"i"}"#
);
}
#[test]
fn remote_urls_and_unknown_types_are_refused() {
assert!(check_image(&Image::DataUrl("https://example.com/a.png".into())).is_err());
assert!(check_image(&Image::DataUrl("data:image/gif;base64,AAAA".into())).is_err());
assert!(check_image(&Image::DataUrl("data:image/png;base64,AAAA".into())).is_ok());
assert!(check_image(&Image::Inline(InlineImage::new("image/webp", "AAAA"))).is_ok());
assert!(check_image(&Image::Inline(InlineImage::new("image/bmp", "AAAA"))).is_err());
assert!(check_image(&Image::Inline(InlineImage::new("image/png", "A A="))).is_err());
assert!(check_image(&Image::DataUrl("data:image/png,AAAA".into())).is_err());
}
}