1use http::{HeaderMap, HeaderValue, header::IF_MATCH};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use thiserror::Error;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SortDirection {
8 Ascending,
9 Descending,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SortTerm {
14 field: String,
15 direction: SortDirection,
16}
17
18impl SortTerm {
19 #[must_use]
20 pub fn field(&self) -> &str {
21 &self.field
22 }
23
24 #[must_use]
25 pub const fn direction(&self) -> SortDirection {
26 self.direction
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(transparent)]
32pub struct Cursor(String);
33
34impl Cursor {
35 pub fn new(value: impl Into<String>) -> Result<Self, ResourceQueryError> {
36 Self::parse(value.into())
37 }
38
39 fn parse(value: String) -> Result<Self, ResourceQueryError> {
40 if value.is_empty()
41 || value.len() > 512
42 || !value
43 .bytes()
44 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
45 {
46 return Err(ResourceQueryError::InvalidCursor);
47 }
48 Ok(Self(value))
49 }
50
51 #[must_use]
52 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ResourceDocument<T> {
59 pub data: T,
60}
61
62impl<T> ResourceDocument<T> {
63 #[must_use]
64 pub const fn new(data: T) -> Self {
65 Self { data }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct CursorPageInfo {
72 pub has_more: bool,
73 pub next_cursor: Option<Cursor>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct ResourceCollection<T> {
78 pub data: Vec<T>,
79 pub page: CursorPageInfo,
80}
81
82impl<T> ResourceCollection<T> {
83 #[must_use]
84 pub const fn new(data: Vec<T>, next_cursor: Option<Cursor>) -> Self {
85 Self {
86 data,
87 page: CursorPageInfo {
88 has_more: next_cursor.is_some(),
89 next_cursor,
90 },
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ResourceListPolicy {
97 default_limit: u16,
98 max_limit: u16,
99 default_sort: Vec<SortTerm>,
100 sort_fields: BTreeSet<String>,
101 filter_fields: BTreeSet<String>,
102}
103
104impl ResourceListPolicy {
105 pub fn new<DS, D, SS, S, FS, F>(
106 default_limit: u16,
107 max_limit: u16,
108 default_sort: DS,
109 sort_fields: SS,
110 filter_fields: FS,
111 ) -> Result<Self, ResourceQueryError>
112 where
113 DS: IntoIterator<Item = D>,
114 D: Into<String>,
115 SS: IntoIterator<Item = S>,
116 S: Into<String>,
117 FS: IntoIterator<Item = F>,
118 F: Into<String>,
119 {
120 if default_limit == 0 || max_limit == 0 || default_limit > max_limit {
121 return Err(ResourceQueryError::InvalidPolicy);
122 }
123 let sort_fields = collect_fields(sort_fields)?;
124 if sort_fields.is_empty() {
125 return Err(ResourceQueryError::InvalidPolicy);
126 }
127 let filter_fields = collect_fields(filter_fields)?;
128 let default_sort = parse_sort(default_sort.into_iter().map(Into::into), &sort_fields)?;
129 if default_sort.is_empty() {
130 return Err(ResourceQueryError::InvalidPolicy);
131 }
132 Ok(Self {
133 default_limit,
134 max_limit,
135 default_sort,
136 sort_fields,
137 filter_fields,
138 })
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ResourceListQuery {
144 limit: u16,
145 after: Option<Cursor>,
146 sort: Vec<SortTerm>,
147 filters: BTreeMap<String, String>,
148}
149
150impl ResourceListQuery {
151 #[must_use]
152 pub const fn limit(&self) -> u16 {
153 self.limit
154 }
155
156 #[must_use]
157 pub const fn after(&self) -> Option<&Cursor> {
158 self.after.as_ref()
159 }
160
161 #[must_use]
162 pub fn sort(&self) -> &[SortTerm] {
163 &self.sort
164 }
165
166 #[must_use]
167 pub const fn filters(&self) -> &BTreeMap<String, String> {
168 &self.filters
169 }
170}
171
172#[derive(Debug, Error, Clone, PartialEq, Eq)]
173pub enum ResourceQueryError {
174 #[error("resource list policy is invalid")]
175 InvalidPolicy,
176 #[error("resource list query encoding is invalid")]
177 InvalidEncoding,
178 #[error("resource list query contains an unsupported parameter")]
179 UnsupportedParameter,
180 #[error("resource list query repeats a parameter")]
181 DuplicateParameter,
182 #[error("page limit is outside the configured bounds")]
183 InvalidLimit,
184 #[error("page cursor is invalid")]
185 InvalidCursor,
186 #[error("resource sort is invalid or not allowlisted")]
187 InvalidSort,
188 #[error("resource filter is invalid or not allowlisted")]
189 InvalidFilter,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct StrongEntityTag {
194 opaque: String,
195 header: HeaderValue,
196}
197
198impl StrongEntityTag {
199 pub fn for_resource(resource: &str, id: &str, revision: u64) -> Result<Self, EntityTagError> {
200 if !valid_tag_component(resource) || !valid_tag_component(id) || revision == 0 {
201 return Err(EntityTagError::InvalidTag);
202 }
203 Self::from_opaque(format!("{resource}:{id}:{revision}"))
204 }
205
206 pub fn from_opaque(opaque: impl Into<String>) -> Result<Self, EntityTagError> {
207 let opaque = opaque.into();
208 if opaque.is_empty()
209 || opaque.len() > 200
210 || !opaque.bytes().all(|byte| {
211 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')
212 })
213 {
214 return Err(EntityTagError::InvalidTag);
215 }
216 let header = HeaderValue::from_str(&format!("\"{opaque}\""))
217 .map_err(|_| EntityTagError::InvalidTag)?;
218 Ok(Self { opaque, header })
219 }
220
221 #[must_use]
222 pub fn opaque(&self) -> &str {
223 &self.opaque
224 }
225
226 #[must_use]
227 pub fn to_header_value(&self) -> HeaderValue {
228 self.header.clone()
229 }
230
231 pub fn resource_revision(&self, resource: &str, id: &str) -> Result<u64, EntityTagError> {
232 if !valid_tag_component(resource) || !valid_tag_component(id) {
233 return Err(EntityTagError::InvalidIfMatch);
234 }
235 self.opaque
236 .strip_prefix(&format!("{resource}:{id}:"))
237 .and_then(|revision| revision.parse::<u64>().ok())
238 .filter(|revision| *revision > 0)
239 .ok_or(EntityTagError::InvalidIfMatch)
240 }
241}
242
243#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
244pub enum EntityTagError {
245 #[error("If-Match is required")]
246 PreconditionRequired,
247 #[error("If-Match must contain exactly one strong Minco entity tag")]
248 InvalidIfMatch,
249 #[error("entity tag input is invalid")]
250 InvalidTag,
251}
252
253pub fn parse_if_match(headers: &HeaderMap) -> Result<StrongEntityTag, EntityTagError> {
254 let mut values = headers.get_all(IF_MATCH).iter();
255 let value = values.next().ok_or(EntityTagError::PreconditionRequired)?;
256 if values.next().is_some() {
257 return Err(EntityTagError::InvalidIfMatch);
258 }
259 let value = value.to_str().map_err(|_| EntityTagError::InvalidIfMatch)?;
260 if value.starts_with("W/") || value.contains(',') {
261 return Err(EntityTagError::InvalidIfMatch);
262 }
263 let opaque = value
264 .strip_prefix('"')
265 .and_then(|value| value.strip_suffix('"'))
266 .ok_or(EntityTagError::InvalidIfMatch)?;
267 StrongEntityTag::from_opaque(opaque).map_err(|_| EntityTagError::InvalidIfMatch)
268}
269
270pub fn parse_resource_list_query(
271 raw_query: Option<&str>,
272 policy: &ResourceListPolicy,
273) -> Result<ResourceListQuery, ResourceQueryError> {
274 let pairs: Vec<(String, String)> = serde_urlencoded::from_str(raw_query.unwrap_or_default())
275 .map_err(|_| ResourceQueryError::InvalidEncoding)?;
276 let mut seen = BTreeSet::new();
277 let mut limit = None;
278 let mut after = None;
279 let mut sort = None;
280 let mut filters = BTreeMap::new();
281
282 for (name, value) in pairs {
283 if !seen.insert(name.clone()) {
284 return Err(ResourceQueryError::DuplicateParameter);
285 }
286 match name.as_str() {
287 "page[limit]" => {
288 let parsed = value
289 .parse::<u16>()
290 .map_err(|_| ResourceQueryError::InvalidLimit)?;
291 if parsed == 0 || parsed > policy.max_limit {
292 return Err(ResourceQueryError::InvalidLimit);
293 }
294 limit = Some(parsed);
295 }
296 "page[after]" => after = Some(Cursor::parse(value)?),
297 "sort" => {
298 sort = Some(parse_sort(
299 value.split(',').map(str::to_owned),
300 &policy.sort_fields,
301 )?);
302 }
303 _ => {
304 let Some(field) = name
305 .strip_prefix("filter[")
306 .and_then(|field| field.strip_suffix(']'))
307 else {
308 return Err(ResourceQueryError::UnsupportedParameter);
309 };
310 if !policy.filter_fields.contains(field)
311 || value.is_empty()
312 || value.len() > 256
313 || value.chars().any(char::is_control)
314 {
315 return Err(ResourceQueryError::InvalidFilter);
316 }
317 filters.insert(field.to_owned(), value);
318 }
319 }
320 }
321
322 Ok(ResourceListQuery {
323 limit: limit.unwrap_or(policy.default_limit),
324 after,
325 sort: sort.unwrap_or_else(|| policy.default_sort.clone()),
326 filters,
327 })
328}
329
330fn collect_fields<I, S>(values: I) -> Result<BTreeSet<String>, ResourceQueryError>
331where
332 I: IntoIterator<Item = S>,
333 S: Into<String>,
334{
335 let mut fields = BTreeSet::new();
336 for value in values {
337 let value = value.into();
338 if !valid_field(&value) || !fields.insert(value) {
339 return Err(ResourceQueryError::InvalidPolicy);
340 }
341 }
342 Ok(fields)
343}
344
345fn parse_sort<I>(values: I, allowed: &BTreeSet<String>) -> Result<Vec<SortTerm>, ResourceQueryError>
346where
347 I: IntoIterator<Item = String>,
348{
349 let mut seen = BTreeSet::new();
350 let mut terms = Vec::new();
351 for value in values {
352 let (direction, field) = value
353 .strip_prefix('-')
354 .map_or((SortDirection::Ascending, value.as_str()), |field| {
355 (SortDirection::Descending, field)
356 });
357 if !valid_field(field) || !allowed.contains(field) || !seen.insert(field.to_owned()) {
358 return Err(ResourceQueryError::InvalidSort);
359 }
360 terms.push(SortTerm {
361 field: field.to_owned(),
362 direction,
363 });
364 }
365 if terms.is_empty() {
366 return Err(ResourceQueryError::InvalidSort);
367 }
368 Ok(terms)
369}
370
371fn valid_field(value: &str) -> bool {
372 let mut bytes = value.bytes();
373 matches!(bytes.next(), Some(first) if first.is_ascii_lowercase())
374 && bytes.all(|byte| byte.is_ascii_alphanumeric())
375}
376
377fn valid_tag_component(value: &str) -> bool {
378 !value.is_empty()
379 && value.len() <= 128
380 && value
381 .bytes()
382 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
383}