Skip to main content

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}