use lino_objects_codec::LinoValue;
use crate::problem::LinoHttpError;
pub const RESERVED_QUERY_PARAMETERS: [&str; 4] = ["limit", "offset", "sort", "fields"];
pub const DEFAULT_LIMIT: usize = 20;
pub const MAX_LIMIT: usize = 100;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct QueryParams {
entries: Vec<(String, String)>,
}
impl QueryParams {
pub fn new() -> Self {
Self::default()
}
pub fn parse(query_string: &str) -> Self {
let trimmed = query_string.strip_prefix('?').unwrap_or(query_string);
let mut entries = Vec::new();
for pair in trimmed.split('&') {
if pair.is_empty() {
continue;
}
let (name, value) = match pair.split_once('=') {
Some((name, value)) => (name, value),
None => (pair, ""),
};
entries.push((form_decode(name), form_decode(value)));
}
Self { entries }
}
pub fn append<N: Into<String>, V: Into<String>>(&mut self, name: N, value: V) {
self.entries.push((name.into(), value.into()));
}
pub fn get_all(&self, name: &str) -> Vec<&str> {
self.entries
.iter()
.filter(|(existing, _)| existing == name)
.map(|(_, value)| value.as_str())
.collect()
}
pub fn get_last(&self, name: &str) -> Option<&str> {
self.entries
.iter()
.rev()
.find(|(existing, _)| existing == name)
.map(|(_, value)| value.as_str())
}
pub fn contains(&self, name: &str) -> bool {
self.get_last(name).is_some()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.entries
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
}
pub fn names(&self) -> Vec<&str> {
let mut names: Vec<&str> = Vec::new();
for (name, _) in &self.entries {
if !names.contains(&name.as_str()) {
names.push(name);
}
}
names
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn to_query_string(&self) -> String {
self.entries
.iter()
.map(|(name, value)| format!("{}={}", form_encode(name), form_encode(value)))
.collect::<Vec<_>>()
.join("&")
}
}
pub fn form_encode(text: &str) -> String {
let mut encoded = String::with_capacity(text.len());
for byte in text.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(*byte as char)
}
b' ' => encoded.push('+'),
other => encoded.push_str(&format!("%{other:02X}")),
}
}
encoded
}
pub fn form_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut decoded: Vec<u8> = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'+' => {
decoded.push(b' ');
index += 1;
}
b'%' if index + 2 < bytes.len() => {
match u8::from_str_radix(&text[index + 1..index + 3], 16) {
Ok(byte) => {
decoded.push(byte);
index += 3;
}
Err(_) => {
decoded.push(b'%');
index += 1;
}
}
}
byte => {
decoded.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&decoded).into_owned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SortKey {
pub field: String,
pub descending: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Filter {
pub field: String,
pub values: Vec<LinoValue>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CollectionQuery {
pub limit: usize,
pub offset: usize,
pub sort: Vec<SortKey>,
pub fields: Option<Vec<String>>,
pub filters: Vec<Filter>,
}
impl Default for CollectionQuery {
fn default() -> Self {
Self {
limit: DEFAULT_LIMIT,
offset: 0,
sort: Vec::new(),
fields: None,
filters: Vec::new(),
}
}
}
pub fn parse_scalar(raw: &str) -> LinoValue {
match raw {
"true" => return LinoValue::Bool(true),
"false" => return LinoValue::Bool(false),
"null" => return LinoValue::Null,
"" => return LinoValue::String(String::new()),
_ => {}
}
if let Ok(number) = raw.parse::<i64>() {
return LinoValue::Int(number);
}
if let Ok(number) = raw.parse::<f64>() {
if number.is_finite() {
return LinoValue::Float(number);
}
}
LinoValue::String(raw.to_string())
}
pub fn parse_bounded_integer(
raw: Option<&str>,
fallback: usize,
name: &str,
maximum: Option<usize>,
) -> Result<usize, LinoHttpError> {
let raw = match raw {
None | Some("") => return Ok(fallback),
Some(raw) => raw,
};
let value: usize = raw.parse().map_err(|_| {
LinoHttpError::new(
400,
Some(&format!(
"Query parameter \"{name}\" must be a non-negative integer"
)),
)
})?;
Ok(match maximum {
Some(maximum) => value.min(maximum),
None => value,
})
}
pub fn parse_sort(raw: Option<&str>) -> Vec<SortKey> {
let Some(raw) = raw.filter(|raw| !raw.is_empty()) else {
return Vec::new();
};
raw.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| match part.strip_prefix('-') {
Some(field) => SortKey {
field: field.to_string(),
descending: true,
},
None => SortKey {
field: part.to_string(),
descending: false,
},
})
.collect()
}
pub fn parse_fields(raw: Option<&str>) -> Option<Vec<String>> {
let raw = raw.filter(|raw| !raw.is_empty())?;
let fields: Vec<String> = raw
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_string)
.collect();
if fields.is_empty() {
None
} else {
Some(fields)
}
}
pub fn parse_collection_query(
query: &QueryParams,
default_limit: usize,
max_limit: usize,
) -> Result<CollectionQuery, LinoHttpError> {
let mut filters = Vec::new();
for name in query.names() {
if RESERVED_QUERY_PARAMETERS.contains(&name) {
continue;
}
filters.push(Filter {
field: name.to_string(),
values: query.get_all(name).into_iter().map(parse_scalar).collect(),
});
}
Ok(CollectionQuery {
limit: parse_bounded_integer(
query.get_last("limit"),
default_limit,
"limit",
Some(max_limit),
)?,
offset: parse_bounded_integer(query.get_last("offset"), 0, "offset", None)?,
sort: parse_sort(query.get_last("sort")),
fields: parse_fields(query.get_last("fields")),
filters,
})
}