Skip to main content

fizzy_sdk/
route.rs

1//! The route table's row type: what the model says about one operation.
2
3use std::fmt::Display;
4
5use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
6
7use crate::error::Error;
8use crate::http::Method;
9
10/// One API operation: its method, its path template and the behaviour the Smithy model
11/// attaches to it. Every route the SDK knows lives in [`crate::routes`].
12#[derive(Debug)]
13#[non_exhaustive]
14pub struct Route {
15    /// The OpenAPI operation id: `ListBoards`.
16    pub id: &'static str,
17    /// The service handle whose method sends this route: `Boards`, `AccessTokens`.
18    pub service: &'static str,
19    /// The HTTP method.
20    pub method: Method,
21    /// The path as Fizzy serves it, `{param}` placeholders included.
22    pub path: &'static str,
23    /// The path without a `.json` suffix, for recognizing pasted URLs.
24    pub pattern: &'static str,
25    /// The path starts with `/{accountId}`, which [`Route::fill`] takes first.
26    pub account_scoped: bool,
27    /// The kind of record the route acts on, snake_cased: `board`, `card`.
28    pub resource_type: &'static str,
29    /// The path parameters after the account, in order.
30    pub params: &'static [RouteParam],
31    /// Safe to resend.
32    pub idempotent: bool,
33    /// The route only reads; nothing it does changes anything.
34    pub readonly: bool,
35    /// How a list continues past its first page.
36    pub pagination: Pagination,
37    /// The retry budget, or `None` for a route that is sent once and never resent.
38    pub retry: Option<Retry>,
39}
40
41/// One `{param}` in a route's path.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct RouteParam {
45    /// The name inside the braces.
46    pub name: &'static str,
47    /// Where it sits.
48    pub role: ParamRole,
49    /// How it is typed.
50    pub kind: ParamKind,
51}
52
53/// Where a path parameter sits: the last segment names the record itself, anything
54/// before it names a parent.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum ParamRole {
58    /// A parent's id.
59    Parent,
60    /// The record's own id.
61    Recording,
62}
63
64/// The scalar types a path parameter takes.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum ParamKind {
68    /// A string, sent verbatim.
69    String,
70    /// A boolean.
71    Bool,
72    /// A 32-bit integer.
73    Int32,
74    /// A 64-bit integer.
75    Int64,
76}
77
78/// How a list continues past its first page.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum Pagination {
82    /// The answer is complete.
83    None,
84    /// The answer carries a `Link: <…>; rel="next"` header naming the next page.
85    Link {
86        /// The query parameter that carries the page cursor.
87        page_param: &'static str,
88    },
89}
90
91/// The retry budget the behavior model gives a route.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub struct Retry {
95    /// Total attempts, the first included.
96    pub max: u32,
97    /// The first backoff, in milliseconds, doubled after every attempt.
98    pub base_delay_ms: u64,
99    /// The statuses that are resent.
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. An
111    /// account-scoped route takes the account first.
112    ///
113    /// # Panics
114    ///
115    /// When `values` is not exactly as long as [`Route::params`] (plus one for the account
116    /// on an account-scoped route). A short list would leave a `{param}` in the path and
117    /// send it to Fizzy as written, which is worse than stopping; every generated caller
118    /// passes the right count, so reaching this means the call was built by hand and built
119    /// wrong.
120    pub fn fill(&self, account_id: Option<&str>, values: &[&dyn Display]) -> String {
121        match self.try_fill(account_id, values) {
122            Ok(path) => path,
123            Err(error) => panic!("{error}"),
124        }
125    }
126
127    /// [`Route::fill`] as a `Result`: a wrong parameter count, a missing account or a value
128    /// that is not one path segment is a usage error rather than a panic, for a caller
129    /// building the operation by hand.
130    pub fn try_fill(
131        &self,
132        account_id: Option<&str>,
133        values: &[&dyn Display],
134    ) -> Result<String, Error> {
135        if values.len() != self.params.len() {
136            return Err(Error::usage(format!(
137                "{} takes {} path parameters, got {}",
138                self.id,
139                self.params.len(),
140                values.len()
141            )));
142        }
143        if account_id.is_some() != self.account_scoped {
144            return Err(Error::usage(format!(
145                "{} {} an account",
146                self.id,
147                if self.account_scoped {
148                    "needs"
149                } else {
150                    "does not take"
151                }
152            )));
153        }
154        for value in values {
155            let value = value.to_string();
156            if value.is_empty() || value == "." || value == ".." {
157                return Err(Error::usage(format!(
158                    "{}: {value:?} is not a path parameter value",
159                    self.id
160                )));
161            }
162        }
163        let mut path = self.path.to_string();
164        if let Some(account_id) = account_id {
165            path = path.replace("{accountId}", &encode(account_id));
166        }
167        for (param, value) in self.params.iter().zip(values) {
168            path = path.replace(&format!("{{{}}}", param.name), &encode(&value.to_string()));
169        }
170        Ok(path)
171    }
172
173    /// Matches a path against the route's pattern and answers the captured parameters, the
174    /// account included, decoded back to what [`Route::fill`] was given.
175    pub fn recognize(&self, path: &str) -> Option<Vec<(&'static str, String)>> {
176        let pattern_segments: Vec<&str> = self.pattern.split('/').collect();
177        let path_segments: Vec<&str> = path.split('/').collect();
178        if pattern_segments.len() != path_segments.len() {
179            return None;
180        }
181        let mut params = Vec::new();
182        for (pattern, actual) in pattern_segments.iter().zip(&path_segments) {
183            if let Some(name) = pattern
184                .strip_prefix('{')
185                .and_then(|rest| rest.strip_suffix('}'))
186            {
187                if actual.is_empty() {
188                    return None;
189                }
190                let name = if name == "accountId" {
191                    "accountId"
192                } else {
193                    self.params.iter().find(|param| param.name == name)?.name
194                };
195                let value = percent_decode_str(actual).decode_utf8().ok()?;
196                params.push((name, value.into_owned()));
197            } else if pattern != actual {
198                return None;
199            }
200        }
201        Some(params)
202    }
203
204    /// The retry budget as attempts, delay and statuses; `None` for a route sent once.
205    pub fn retry(&self) -> Option<Retry> {
206        self.retry
207    }
208}
209
210/// Percent-encodes one path segment.
211pub(crate) fn encode(value: &str) -> String {
212    utf8_percent_encode(value, PATH_SEGMENT).to_string()
213}