Skip to content

Commit 4015d81

Browse files
authored
Merge pull request #351 from AdExNetwork/api-endpoint-url
Api Url & safe endpoint
2 parents fc4c2c9 + 3d3be4a commit 4015d81

File tree

2 files changed

+237
-0
lines changed

2 files changed

+237
-0
lines changed

primitives/src/lib.rs

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

2222
pub mod util {
23+
pub use api::ApiUrl;
24+
25+
pub mod api;
2326
pub mod tests {
2427
use slog::{o, Discard, Drain, Logger};
2528

primitives/src/util/api.rs

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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;
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, Hash, Display, Ord, PartialOrd, Eq, PartialEq, Deserialize, Serialize)]
39+
#[serde(try_from = "Url", into = "Url")]
40+
pub struct ApiUrl(Url);
41+
42+
impl ApiUrl {
43+
pub fn parse(input: &str) -> Result<Self, Error> {
44+
Self::from_str(input)
45+
}
46+
47+
/// The Endpoint of which we want to get an url to (strips prefixed `/` from the endpoint),
48+
/// which can can include:
49+
/// - path
50+
/// - query
51+
/// - fragments - usually should not be used for requesting API resources from server
52+
/// This method does **not** check if a file is present
53+
/// This method strips the starting `/` of the endpoint, if there is one
54+
pub fn join(&self, endpoint: &str) -> Result<Url, url::ParseError> {
55+
let stripped = endpoint.strip_prefix('/').unwrap_or(endpoint);
56+
// this join is safe, since we always prefix the Url with `/`
57+
self.0.join(stripped)
58+
}
59+
}
60+
61+
impl fmt::Debug for ApiUrl {
62+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63+
write!(f, "Url({})", self)
64+
}
65+
}
66+
67+
impl TryFrom<Url> for ApiUrl {
68+
type Error = Error;
69+
70+
fn try_from(mut url: Url) -> Result<Self, Self::Error> {
71+
if url.cannot_be_a_base() {
72+
return Err(Error::ShouldBeABase);
73+
}
74+
75+
if url.fragment().is_some() {
76+
return Err(Error::HasFragment);
77+
}
78+
79+
if !SCHEMES.contains(&url.scheme()) {
80+
return Err(Error::InvalidScheme(url.scheme().to_string()));
81+
}
82+
83+
if url.query().is_some() {
84+
return Err(Error::HasQuery);
85+
}
86+
87+
let url_path = url.path();
88+
89+
let mut stripped_path = url_path.strip_suffix('/').unwrap_or(url_path).to_string();
90+
// Make sure to always end the path with `/`!
91+
stripped_path.push('/');
92+
93+
url.set_path(&stripped_path);
94+
95+
Ok(Self(url))
96+
}
97+
}
98+
99+
impl Into<Url> for ApiUrl {
100+
fn into(self) -> Url {
101+
self.0
102+
}
103+
}
104+
105+
impl FromStr for ApiUrl {
106+
type Err = Error;
107+
108+
fn from_str(s: &str) -> Result<Self, Self::Err> {
109+
Self::try_from(s.parse::<Url>()?)
110+
}
111+
}
112+
113+
#[cfg(test)]
114+
mod test {
115+
use url::ParseError;
116+
117+
use super::*;
118+
119+
#[test]
120+
fn api_url() {
121+
let allowed = vec![
122+
// Http
123+
(
124+
"http://127.0.0.1/",
125+
ApiUrl::from_str("http://127.0.0.1/").unwrap(),
126+
),
127+
(
128+
"http://127.0.0.1",
129+
ApiUrl::from_str("http://127.0.0.1/").unwrap(),
130+
),
131+
// Https
132+
(
133+
"https://127.0.0.1/",
134+
ApiUrl::from_str("https://127.0.0.1/").unwrap(),
135+
),
136+
(
137+
"https://127.0.0.1",
138+
ApiUrl::from_str("https://127.0.0.1/").unwrap(),
139+
),
140+
// Domain `/` suffixed
141+
(
142+
"https://jerry.adex.network/",
143+
ApiUrl::from_str("https://jerry.adex.network/").unwrap(),
144+
),
145+
(
146+
"https://tom.adex.network",
147+
ApiUrl::from_str("https://tom.adex.network/").unwrap(),
148+
),
149+
// With Port
150+
(
151+
"https://localhost:3335",
152+
ApiUrl::from_str("https://localhost:3335/").unwrap(),
153+
),
154+
// With Path non `/` suffixed
155+
(
156+
"https://localhost/leader",
157+
ApiUrl::from_str("https://localhost/leader/").unwrap(),
158+
),
159+
// With Path `/` suffixed
160+
(
161+
"https://localhost/leader/",
162+
ApiUrl::from_str("https://localhost/leader/").unwrap(),
163+
),
164+
// with authority
165+
(
166+
"https://username:password@localhost",
167+
ApiUrl::from_str("https://username:password@localhost/").unwrap(),
168+
),
169+
// HTTPS, authority, domain, port and path
170+
(
171+
"https://username:password@jerry.adex.network:3335/leader",
172+
ApiUrl::from_str("https://username:password@jerry.adex.network:3335/leader")
173+
.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+
#[test]
212+
fn api_endpoint() {
213+
let api_url = ApiUrl::parse("http://127.0.0.1/leader").expect("It is a valid API URL");
214+
215+
let expected = url::Url::parse("http://127.0.0.1/leader/endpoint?query=query value")
216+
.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
220+
.join("endpoint?query=query value")
221+
.expect("Should join endpoint");
222+
let actual_should_strip_suffix = api_url
223+
.join("/endpoint?query=query value")
224+
.expect("Should join endpoint and strip `/` suffix and preserve the original ApiUrl");
225+
assert_eq!(&expected, &actual);
226+
assert_eq!(&expected, &actual_should_strip_suffix);
227+
228+
assert_eq!(expected_url_encoded, &actual.to_string());
229+
assert_eq!(
230+
expected_url_encoded,
231+
&actual_should_strip_suffix.to_string()
232+
);
233+
}
234+
}

0 commit comments

Comments
 (0)