hey_sdk/route.rs
1use std::fmt::Display;
2
3use crate::http::Method;
4use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
5
6/// One API operation: its method, its path template and the behaviour the Smithy model
7/// attaches to it. Every route the SDK knows lives in [`crate::routes`].
8#[derive(Debug)]
9pub struct Route {
10 /// The operation as the model names it: `ListBoxes`, `GetTopic`.
11 pub id: &'static str,
12 /// The service handle whose method sends this route, as every HEY SDK names it:
13 /// `Boxes`, `TimeTracks`.
14 pub service: &'static str,
15 /// The HTTP method the route is sent with.
16 pub method: Method,
17 /// The path as HEY serves it, `{param}` placeholders included.
18 pub path: &'static str,
19 /// The path without a `.json` suffix, for recognizing pasted URLs.
20 pub pattern: &'static str,
21 /// The part of HEY the route belongs to, as the model titles it: `Boxes`,
22 /// `Calendar Time Tracks`.
23 pub resource: &'static str,
24 /// The kind of record the route acts on, in `snake_case`: `box`, `box_group`.
25 pub resource_type: &'static str,
26 /// The path parameters, in the order they appear in [`Route::path`].
27 pub params: &'static [RouteParam],
28 /// The route may be sent again after a failure without doing its work twice.
29 pub idempotent: bool,
30 /// The route only reads; nothing it does changes anything.
31 pub readonly: bool,
32 /// The route answers a page as HTML rather than a JSON document, so it is asked for as
33 /// written — no `.json` suffix — with `Accept: text/html`.
34 pub html: bool,
35 /// The statuses that mean HEY has nothing for this route rather than that it failed — a
36 /// 404 for a record that may simply not be there. Such an answer is empty, not an error.
37 pub empty_on: &'static [u16],
38 /// How the route pages, when it does.
39 pub pagination: Pagination,
40 /// The retry policy the model attaches to the route.
41 pub retry: Retry,
42}
43
44/// One `{param}` placeholder in a route's path.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct RouteParam {
47 /// The placeholder's name as it appears in the path: `boxId`.
48 pub name: &'static str,
49 /// Whether the parameter names the record itself or a parent of it.
50 pub role: ParamRole,
51 /// The type the model gives the value.
52 pub kind: ParamKind,
53}
54
55/// Where a path parameter sits: the last segment names the record itself, anything
56/// before it names a parent.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ParamRole {
59 /// Names a record the one the route acts on belongs to: the box a group is in.
60 Parent,
61 /// Names the record the route acts on.
62 Recording,
63}
64
65/// The type the model gives a path parameter's value.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum ParamKind {
69 /// Any text, such as a slug or a token.
70 String,
71 /// `true` or `false`.
72 Bool,
73 /// A 32-bit integer.
74 Int32,
75 /// A 64-bit integer, which is what a HEY record id is.
76 Int64,
77}
78
79/// How a route pages its answer.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81#[non_exhaustive]
82pub enum Pagination {
83 /// The whole answer comes at once.
84 None,
85 /// HEY names the next page in a `Link` header; see [`crate::Page`].
86 Link,
87 /// The read covers a window of dates, and the caller moves the window to read on.
88 Window,
89}
90
91/// The retry policy the model attaches to a route: how many attempts, how long the first
92/// wait is, and which statuses are worth another try.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct Retry {
95 /// The most attempts the route is given, the first one included.
96 pub max: u32,
97 /// The wait before the second attempt, in milliseconds; later waits grow from it.
98 pub base_delay_ms: u64,
99 /// The statuses that are worth another attempt.
100 pub retry_on: &'static [u16],
101}
102
103const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
104 .remove(b'-')
105 .remove(b'_')
106 .remove(b'.')
107 .remove(b'~');
108
109impl Route {
110 /// Substitutes the path parameters, in order, percent-encoding each value.
111 ///
112 /// # Panics
113 ///
114 /// When `values` is not exactly as long as [`Route::params`]. A short list would leave
115 /// a `{param}` in the path and send it to HEY as written, which is worse than stopping;
116 /// every generated caller passes the right count, so reaching this means the call was
117 /// built by hand and built wrong.
118 pub fn fill(&self, values: &[&dyn Display]) -> String {
119 assert_eq!(
120 values.len(),
121 self.params.len(),
122 "{} takes {} path parameters",
123 self.id,
124 self.params.len()
125 );
126 let mut path = self.path.to_string();
127 for (param, value) in self.params.iter().zip(values) {
128 let encoded = utf8_percent_encode(&value.to_string(), PATH_SEGMENT).to_string();
129 path = path.replace(&format!("{{{}}}", param.name), &encoded);
130 }
131 path
132 }
133
134 /// Matches a path against the route's pattern and answers the captured parameters.
135 pub fn recognize(&self, path: &str) -> Option<Vec<(&'static str, String)>> {
136 let pattern_segments: Vec<&str> = self.pattern.split('/').collect();
137 let path_segments: Vec<&str> = path.split('/').collect();
138 if pattern_segments.len() != path_segments.len() {
139 return None;
140 }
141 let mut params = Vec::new();
142 for (pattern, actual) in pattern_segments.iter().zip(&path_segments) {
143 if let Some(name) = pattern
144 .strip_prefix('{')
145 .and_then(|rest| rest.strip_suffix('}'))
146 {
147 if actual.is_empty() {
148 return None;
149 }
150 let param = self.params.iter().find(|param| param.name == name)?;
151 params.push((param.name, actual.to_string()));
152 } else if pattern != actual {
153 return None;
154 }
155 }
156 Some(params)
157 }
158}