ledgence_orchestration_api/
discovery.rs1use crate::*;
4use serde::Deserializer;
5
6pub const TASK_LIST_DEFAULT_LIMIT: u32 = 50;
7pub const TASK_LIST_MAX_LIMIT: u32 = 100;
8pub const TASK_CURSOR_MAX_BYTES: usize = 8192;
9pub const TASK_PAGE_MAX_BYTES: usize = 2 * 1024 * 1024;
10const MAX_TIMESTAMP: Timestamp = 253_402_300_799_999;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct TaskFilters {
16 pub state: Option<TaskState>,
17 pub queue: Option<String>,
18 pub submitted_from: Option<Timestamp>,
19 pub submitted_until: Option<Timestamp>,
20 pub correlation_key: Option<String>,
22}
23
24impl TaskFilters {
25 pub fn validate(&self) -> Result<()> {
26 if let Some(queue) = &self.queue {
27 validate_text(queue, 128)?;
28 }
29 if let Some(key) = &self.correlation_key
30 && (key.len() > 512 || key.chars().any(char::is_control))
31 {
32 return Err(invalid("invalid correlation key filter"));
33 }
34 if [self.submitted_from, self.submitted_until]
35 .into_iter()
36 .flatten()
37 .any(|at| at > MAX_TIMESTAMP)
38 || matches!((self.submitted_from, self.submitted_until), (Some(from), Some(until)) if from >= until)
39 {
40 return Err(invalid("invalid submission time range"));
41 }
42 Ok(())
43 }
44
45 pub fn matches(&self, task: &TaskStatus) -> bool {
47 self.state.is_none_or(|state| state == task.state)
48 && self.queue.as_ref().is_none_or(|queue| queue == &task.queue)
49 && self
50 .submitted_from
51 .is_none_or(|from| task.submitted_at >= from)
52 && self
53 .submitted_until
54 .is_none_or(|until| task.submitted_at < until)
55 && self
56 .correlation_key
57 .as_ref()
58 .is_none_or(|key| task.correlation_key.as_ref() == Some(key))
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct TaskListQuery {
66 #[serde(default)]
67 pub filters: TaskFilters,
68 #[serde(default = "default_limit")]
69 pub limit: u32,
70 pub cursor: Option<String>,
71}
72
73impl Default for TaskListQuery {
74 fn default() -> Self {
75 Self {
76 filters: TaskFilters::default(),
77 limit: TASK_LIST_DEFAULT_LIMIT,
78 cursor: None,
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct TaskPosition {
87 pub submitted_at: Timestamp,
88 pub task_id: String,
89}
90impl From<&TaskStatus> for TaskPosition {
91 fn from(task: &TaskStatus) -> Self {
92 Self {
93 submitted_at: task.submitted_at,
94 task_id: task.task_id.clone(),
95 }
96 }
97}
98impl TaskPosition {
99 fn validate(&self, filters: &TaskFilters) -> Result<()> {
100 validate_text(&self.task_id, 128)?;
101 if self.submitted_at > MAX_TIMESTAMP
102 || filters
103 .submitted_from
104 .is_some_and(|from| self.submitted_at < from)
105 || filters
106 .submitted_until
107 .is_some_and(|until| self.submitted_at >= until)
108 {
109 return Err(invalid("invalid cursor position"));
110 }
111 Ok(())
112 }
113}
114
115#[derive(Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117struct Cursor {
118 version: u32,
119 scope: Scope,
120 filters: TaskFilters,
121 position: TaskPosition,
122}
123
124impl TaskListQuery {
125 pub fn validate(&self, scope: &Scope) -> Result<Option<TaskPosition>> {
127 scope.validate()?;
128 self.filters.validate()?;
129 if !(1..=TASK_LIST_MAX_LIMIT).contains(&self.limit) {
130 return Err(invalid("task list limit must be between 1 and 100"));
131 }
132 self.cursor
133 .as_deref()
134 .map(|cursor| self.decode_cursor(scope, cursor))
135 .transpose()
136 }
137
138 pub fn next_cursor(&self, scope: &Scope, position: &TaskPosition) -> Result<String> {
140 self.validate(scope)?;
141 position.validate(&self.filters)?;
142 let bytes = serde_json::to_vec(&Cursor {
143 version: 1,
144 scope: scope.clone(),
145 filters: self.filters.clone(),
146 position: position.clone(),
147 })
148 .map_err(|_| invalid("could not encode task cursor"))?;
149 const HEX: &[u8; 16] = b"0123456789abcdef";
150 let mut cursor = String::with_capacity(bytes.len() * 2);
151 for byte in bytes {
152 cursor.push(char::from(HEX[usize::from(byte >> 4)]));
153 cursor.push(char::from(HEX[usize::from(byte & 15)]));
154 }
155 if cursor.len() > TASK_CURSOR_MAX_BYTES {
156 return Err(invalid("task cursor exceeds size limit"));
157 }
158 Ok(cursor)
159 }
160
161 fn decode_cursor(&self, scope: &Scope, text: &str) -> Result<TaskPosition> {
162 if text.is_empty() || text.len() > TASK_CURSOR_MAX_BYTES || !text.len().is_multiple_of(2) {
163 return Err(invalid("invalid task cursor"));
164 }
165 let digit = |byte| match byte {
166 b'0'..=b'9' => Ok(byte - b'0'),
167 b'a'..=b'f' => Ok(byte - b'a' + 10),
168 _ => Err(invalid("invalid task cursor")),
169 };
170 let bytes = text
171 .as_bytes()
172 .as_chunks::<2>()
173 .0
174 .iter()
175 .map(|pair| Ok((digit(pair[0])? << 4) | digit(pair[1])?))
176 .collect::<Result<Vec<u8>>>()?;
177 let cursor: Cursor = decode_unique_json(&bytes, TASK_CURSOR_MAX_BYTES / 2)
178 .map_err(|_| invalid("invalid task cursor"))?;
179 if cursor.version != 1 || cursor.scope != *scope || cursor.filters != self.filters {
180 return Err(invalid("task cursor does not match scope or filters"));
181 }
182 cursor.position.validate(&self.filters)?;
183 Ok(cursor.position)
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct TaskPage {
191 pub items: Vec<TaskStatus>,
192 #[serde(deserialize_with = "required_option")]
193 pub next_cursor: Option<String>,
194}
195impl TaskPage {
196 pub fn validate(&self, scope: &Scope, query: &TaskListQuery) -> Result<()> {
198 let mut previous = query.validate(scope)?;
199 let inconsistent = || ContractError::Unavailable("inconsistent task page".into());
200 if self.items.len() > query.limit as usize {
201 return Err(inconsistent());
202 }
203 let mut ids = std::collections::HashSet::with_capacity(self.items.len());
204 for task in &self.items {
205 task.validate()?;
206 let position = TaskPosition::from(task);
207 if task.scope != *scope
208 || !query.filters.matches(task)
209 || previous
210 .as_ref()
211 .is_some_and(|previous| position >= *previous)
212 || !ids.insert(&task.task_id)
213 {
214 return Err(inconsistent());
215 }
216 previous = Some(position);
217 }
218 if let Some(cursor) = &self.next_cursor {
219 let position = query
220 .decode_cursor(scope, cursor)
221 .map_err(|_| inconsistent())?;
222 if self.items.len() != query.limit as usize
223 || self.items.last().map(TaskPosition::from).as_ref() != Some(&position)
224 {
225 return Err(inconsistent());
226 }
227 }
228 crate::submission::check_encoded_size(self, TASK_PAGE_MAX_BYTES, "task page")
229 .map_err(|_| inconsistent())
230 }
231}
232
233fn default_limit() -> u32 {
234 TASK_LIST_DEFAULT_LIMIT
235}
236fn invalid(message: &str) -> ContractError {
237 ContractError::InvalidInput(message.into())
238}
239fn required_option<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
240where
241 D: Deserializer<'de>,
242 T: Deserialize<'de>,
243{
244 Option::<T>::deserialize(deserializer)
245}
246
247#[cfg(test)]
248#[path = "discovery_tests.rs"]
249mod tests;