Skip to content

Commit cc5b1c4

Browse files
committed
util::api::Url
1 parent fc4c2c9 commit cc5b1c4

File tree

2 files changed

+228
-0
lines changed

2 files changed

+228
-0
lines changed

primitives/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub mod supermarket;
2020
pub mod targeting;
2121

2222
pub mod util {
23+
pub mod api;
2324
pub mod tests {
2425
use slog::{o, Discard, Drain, Logger};
2526

primitives/src/util/api.rs

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
use std::{convert::TryFrom, fmt, str::FromStr};
2+
3+
use parse_display::Display;
4+
use serde::{Deserialize, Serialize};
5+
use thiserror::Error;
6+
use url::Url as Url;
7+
8+
// `url::Url::scheme()` returns lower-cased ASCII string without `:`
9+
const SCHEMES: [&str; 2] = ["http", "https"];
10+
11+
#[derive(Debug, Error, PartialEq)]
12+
pub enum Error {
13+
#[error("Invalid scheme '{0}', only 'http' & 'https' are allowed")]
14+
InvalidScheme(String),
15+
#[error("The Url has to be a base, i.e. `data:`, `mailto:` etc. are not allowed")]
16+
ShouldBeABase,
17+
#[error("Having a fragment (i.e. `#fragment`) is not allowed")]
18+
HasFragment,
19+
#[error("Having a query parameters (i.e. `?query_param=value`) is not allowed")]
20+
HasQuery,
21+
#[error("Parsing the url: {0}")]
22+
Parsing(#[from] url::ParseError),
23+
}
24+
25+
/// A safe Url to use in REST API calls.
26+
///
27+
/// It makes sure to always end the Url with `/`,
28+
/// however it doesn't check for the existence of a file, e.g. `/path/a-file.html`
29+
///
30+
/// Underneath it uses [`url::Url`], so all the validation from there is enforced,
31+
/// with additional validation which doesn't allow having:
32+
/// - `Scheme` different that `http` & `https`
33+
/// - Non-base `url`s like `data:` & `mailto:`
34+
/// - `Fragment`, e.g. `#fragment`
35+
/// - `Query`, e.g. `?query_param=value`, `?query_param`, `?query=value&....`, etc.
36+
///
37+
/// [`url::Url`]: url::Url
38+
#[derive(Clone, Display, PartialEq, Deserialize, Serialize)]
39+
#[serde(try_from = "Url", into = "Url")]
40+
pub struct ApiUrl(Url);
41+
42+
43+
impl ApiUrl {
44+
pub fn parse(input: &str) -> Result<Self, Error> {
45+
Self::from_str(input)
46+
}
47+
48+
/// The Endpoint of which we want to get an url to (strips prefixed `/` from the endpoint),
49+
/// which can can include:
50+
/// - path
51+
/// - query
52+
/// - fragments - usually should not be used for requesting API resources from server
53+
/// This method does **not** check if a file is present
54+
/// This method strips the starting `/` of the endpoint, if there is one
55+
pub fn join(&self, endpoint: &str) -> Result<Url, url::ParseError> {
56+
let stripped = endpoint.strip_prefix('/').unwrap_or(endpoint);
57+
// this join is safe, since we always prefix the Url with `/`
58+
self.0.join(stripped)
59+
}
60+
}
61+
62+
impl fmt::Debug for ApiUrl {
63+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64+
write!(f, "Url({})", self)
65+
}
66+
}
67+
68+
impl TryFrom<Url> for ApiUrl {
69+
type Error = Error;
70+
71+
fn try_from(mut url: Url) -> Result<Self, Self::Error> {
72+
if url.cannot_be_a_base() {
73+
return Err(Error::ShouldBeABase);
74+
}
75+
76+
if let Some(_) = url.fragment() {
77+
return Err(Error::HasFragment);
78+
}
79+
80+
if !SCHEMES.contains(&url.scheme()) {
81+
return Err(Error::InvalidScheme(url.scheme().to_string()));
82+
}
83+
84+
if let Some(_) = url.query() {
85+
return Err(Error::HasQuery);
86+
}
87+
88+
let url_path = url.path();
89+
90+
let mut stripped_path = url_path.strip_suffix('/').unwrap_or(url_path).to_string();
91+
// Make sure to always end the path with `/`!
92+
stripped_path.push('/');
93+
94+
url.set_path(&stripped_path);
95+
96+
Ok(Self(url))
97+
}
98+
}
99+
100+
impl Into<Url> for ApiUrl {
101+
fn into(self) -> Url {
102+
self.0
103+
}
104+
}
105+
106+
impl FromStr for ApiUrl {
107+
type Err = Error;
108+
109+
fn from_str(s: &str) -> Result<Self, Self::Err> {
110+
Self::try_from(s.parse::<Url>()?)
111+
}
112+
}
113+
114+
#[cfg(test)]
115+
mod test {
116+
use url::ParseError;
117+
118+
use super::*;
119+
120+
#[test]
121+
fn api_url() {
122+
let allowed = vec![
123+
// Http
124+
(
125+
"http://127.0.0.1/",
126+
ApiUrl::from_str("http://127.0.0.1/").unwrap(),
127+
),
128+
(
129+
"http://127.0.0.1",
130+
ApiUrl::from_str("http://127.0.0.1/").unwrap(),
131+
),
132+
// Https
133+
(
134+
"https://127.0.0.1/",
135+
ApiUrl::from_str("https://127.0.0.1/").unwrap(),
136+
),
137+
(
138+
"https://127.0.0.1",
139+
ApiUrl::from_str("https://127.0.0.1/").unwrap(),
140+
),
141+
// Domain `/` suffixed
142+
(
143+
"https://jerry.adex.network/",
144+
ApiUrl::from_str("https://jerry.adex.network/").unwrap(),
145+
),
146+
(
147+
"https://tom.adex.network",
148+
ApiUrl::from_str("https://tom.adex.network/").unwrap(),
149+
),
150+
// With Port
151+
(
152+
"https://localhost:3335",
153+
ApiUrl::from_str("https://localhost:3335/").unwrap(),
154+
),
155+
// With Path non `/` suffixed
156+
(
157+
"https://localhost/leader",
158+
ApiUrl::from_str("https://localhost/leader/").unwrap(),
159+
),
160+
// With Path `/` suffixed
161+
(
162+
"https://localhost/leader/",
163+
ApiUrl::from_str("https://localhost/leader/").unwrap(),
164+
),
165+
// with authority
166+
(
167+
"https://username:password@localhost",
168+
ApiUrl::from_str("https://username:password@localhost/").unwrap(),
169+
),
170+
// HTTPS, authority, domain, port and path
171+
(
172+
"https://username:password@jerry.adex.network:3335/leader",
173+
ApiUrl::from_str("https://username:password@jerry.adex.network:3335/leader").unwrap(),
174+
),
175+
];
176+
177+
let failing = vec![
178+
// Unix socket
179+
(
180+
"unix:/run/foo.socket",
181+
Error::InvalidScheme("unix".to_string()),
182+
),
183+
// A file URL
184+
(
185+
"file://127.0.0.1/",
186+
Error::InvalidScheme("file".to_string()),
187+
),
188+
// relative path
189+
(
190+
"/relative/path",
191+
Error::Parsing(ParseError::RelativeUrlWithoutBase),
192+
),
193+
(
194+
"/relative/path/",
195+
Error::Parsing(ParseError::RelativeUrlWithoutBase),
196+
),
197+
// blob
198+
("data:text/plain,Stuff", Error::ShouldBeABase),
199+
];
200+
201+
for (case, expected) in allowed {
202+
let url = case.parse::<ApiUrl>();
203+
assert_eq!(url, Ok(expected))
204+
}
205+
206+
for (case, error) in failing {
207+
assert_eq!(case.parse::<ApiUrl>(), Err(error))
208+
}
209+
}
210+
211+
212+
#[test]
213+
fn api_endpoint() {
214+
let api_url = ApiUrl::parse("http://127.0.0.1/leader").expect("It is a valid API URL");
215+
216+
let expected = url::Url::parse("http://127.0.0.1/leader/endpoint?query=query value").expect("it is a valid Url");
217+
let expected_url_encoded = "http://127.0.0.1/leader/endpoint?query=query%20value";
218+
219+
let actual = api_url.join("endpoint?query=query value").expect("Should join endpoint");
220+
let actual_should_strip_suffix = api_url.join("/endpoint?query=query value").expect("Should join endpoint and strip `/` suffix and preserve the original ApiUrl");
221+
assert_eq!(&expected, &actual);
222+
assert_eq!(&expected, &actual_should_strip_suffix);
223+
224+
assert_eq!(expected_url_encoded, &actual.to_string());
225+
assert_eq!(expected_url_encoded, &actual_should_strip_suffix.to_string());
226+
}
227+
}

0 commit comments

Comments
 (0)