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}