Skip to content

Commit 2cecdfc

Browse files
ikalnytskyimalor
andcommitted
Add GET /snippets route
We already have `POST /snippets` route that creates new snippets, but we were missing `GET /snippets` route to list recently created snippets. This patch implements missing route. Co-Authored-By: Roman Podoliaka <[email protected]>
1 parent 6294e03 commit 2cecdfc

15 files changed

+762
-51
lines changed

Cargo.lock

Lines changed: 10 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@ diesel = { version = "1.4.5", features = ["chrono", "postgres", "r2d2"] }
2424
jsonwebtoken = "7.2.0"
2525
rand = "0.7.3"
2626
reqwest = { version = "0.11.2", features = ["blocking", "json"] }
27-
rocket = "0.4.5"
28-
rocket_contrib = {version = "0.4.5", features = ["json"]}
27+
rocket = "0.4.10"
28+
rocket_contrib = {version = "0.4.10", features = ["json"]}
2929
serde = { version = "1.0", features = ["derive"] }
3030
serde_json = "1.0"
3131
tracing = "0.1.25"
3232
tracing-subscriber = "0.2.16"
3333
uuid = { version = "0.8.2", features = ["v4"] }
34+
percent-encoding = "2.1.0"
3435

3536
[dev-dependencies]
3637
tempfile = "3.2.0"

src/application.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ pub fn create_app() -> Result<rocket::Rocket, Box<dyn Error>> {
6868

6969
let routes = routes![
7070
routes::snippets::create_snippet,
71+
routes::snippets::list_snippets,
7172
routes::snippets::get_snippet,
7273
routes::syntaxes::get_syntaxes,
7374
routes::snippets::import_snippet,

src/errors.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ impl ApiError {
3232
/// Reason why the request failed.
3333
pub fn reason(&self) -> &str {
3434
match self {
35-
ApiError::BadRequest(msg) => &msg,
36-
ApiError::Forbidden(msg) => &msg,
37-
ApiError::NotAcceptable(msg) => &msg,
38-
ApiError::Conflict(msg) => &msg,
39-
ApiError::NotFound(msg) => &msg,
40-
ApiError::InternalError(msg) => &msg,
41-
ApiError::UnsupportedMediaType(msg) => &msg,
35+
ApiError::BadRequest(msg) => msg,
36+
ApiError::Forbidden(msg) => msg,
37+
ApiError::NotAcceptable(msg) => msg,
38+
ApiError::Conflict(msg) => msg,
39+
ApiError::NotFound(msg) => msg,
40+
ApiError::InternalError(msg) => msg,
41+
ApiError::UnsupportedMediaType(msg) => msg,
4242
}
4343
}
4444

src/routes/snippets.rs

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
use std::convert::TryFrom;
22

3+
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
34
use rocket::http::uri::Origin;
5+
use rocket::http::{HeaderMap, RawStr};
46
use rocket::response::status::Created;
57
use rocket::State;
68
use serde::Deserialize;
79

810
use crate::application::Config;
911
use crate::errors::ApiError;
10-
use crate::storage::{Changeset, DateTime, Snippet, Storage};
11-
use crate::web::{BearerAuth, Input, NegotiatedContentType, Output};
12+
use crate::storage::{Changeset, DateTime, Direction, ListSnippetsQuery, Snippet, Storage};
13+
use crate::web::{
14+
BearerAuth, Input, NegotiatedContentType, Output, PaginationLimit, WithHttpHeaders,
15+
};
1216

1317
fn create_snippet_impl(
1418
storage: &dyn Storage,
@@ -21,6 +25,68 @@ fn create_snippet_impl(
2125
Ok(Created(location, Some(Output(new_snippet))))
2226
}
2327

28+
fn create_link_header(
29+
origin: &Origin,
30+
next_marker: Option<String>,
31+
prev_marker: Option<String>,
32+
prev_needed: bool,
33+
) -> String {
34+
const QUERY_ENCODE_SET: &AsciiSet = &CONTROLS
35+
.add(b' ')
36+
.add(b'"')
37+
.add(b'#')
38+
.add(b'<')
39+
.add(b'>')
40+
.add(b'&');
41+
42+
let query_wo_marker = origin.query().map(|q| {
43+
q.split('&')
44+
.filter_map(|v| {
45+
let v = RawStr::from_str(v).percent_decode_lossy();
46+
if !v.starts_with("marker=") {
47+
Some(utf8_percent_encode(&v, QUERY_ENCODE_SET).to_string())
48+
} else {
49+
None
50+
}
51+
})
52+
.collect::<Vec<_>>()
53+
.join("&")
54+
});
55+
let query_first = query_wo_marker.clone();
56+
let mut query_next = next_marker.map(|marker| format!("marker={}", marker));
57+
let mut query_prev = prev_marker.map(|marker| format!("marker={}", marker));
58+
59+
// If a request URL does contain query parameters (other than marker), we
60+
// must reuse them together with next/prev markers.
61+
if let Some(query_wo_marker) = query_wo_marker {
62+
query_next = query_next.map(|query| format!("{}&{}", query_wo_marker, query));
63+
query_prev = query_prev.map(|query| format!("{}&{}", query_wo_marker, query));
64+
}
65+
66+
// If a previous page is the first page, we don't have 'prev_marker' set
67+
// yet the link must be generated. If that's the case, reuse query
68+
// parameters we are using to generate a link to the first page.
69+
if query_prev.is_none() && prev_needed {
70+
query_prev = query_first.clone();
71+
}
72+
73+
vec![
74+
// (query string, rel, is_required)
75+
(&query_first, "first", true),
76+
(&query_next, "next", query_next.is_some()),
77+
(&query_prev, "prev", prev_needed),
78+
]
79+
.into_iter()
80+
.filter(|item| item.2)
81+
.map(|item| match item.0 {
82+
Some(query) => (vec![origin.path(), query.as_str()].join("?"), item.1),
83+
None => (origin.path().to_owned(), item.1),
84+
})
85+
.map(|item| format!("<{}>; rel=\"{}\"", item.0, item.1))
86+
.collect::<Vec<_>>()
87+
.join(", ")
88+
}
89+
2490
#[derive(Deserialize)]
2591
#[serde(deny_unknown_fields)]
2692
pub struct NewSnippet {
@@ -77,6 +143,68 @@ pub fn create_snippet(
77143
create_snippet_impl(&**storage, &snippet, origin.path())
78144
}
79145

146+
fn split_marker(mut snippets: Vec<Snippet>, limit: usize) -> (Option<String>, Vec<Snippet>) {
147+
if snippets.len() > limit {
148+
snippets.truncate(limit);
149+
(snippets.last().map(|m| m.id.to_owned()), snippets)
150+
} else {
151+
(None, snippets)
152+
}
153+
}
154+
155+
#[allow(clippy::too_many_arguments)]
156+
#[get("/snippets?<title>&<syntax>&<tag>&<marker>&<limit>")]
157+
pub fn list_snippets<'o, 'h>(
158+
storage: State<Box<dyn Storage>>,
159+
origin: &'o Origin,
160+
title: Option<String>,
161+
syntax: Option<String>,
162+
tag: Option<String>,
163+
limit: Option<Result<PaginationLimit, ApiError>>,
164+
marker: Option<String>,
165+
_content_type: NegotiatedContentType,
166+
_user: BearerAuth,
167+
) -> Result<WithHttpHeaders<'h, Output<Vec<Snippet>>>, ApiError> {
168+
let mut criteria = ListSnippetsQuery {
169+
title,
170+
syntax,
171+
tags: tag.map(|v| vec![v]),
172+
..Default::default()
173+
};
174+
175+
// Fetch one more record in order to detect if there's a next page, and
176+
// generate appropriate Link entry accordingly.
177+
let limit = limit
178+
.unwrap_or_else(|| Ok(<PaginationLimit as Default>::default()))?
179+
.0;
180+
criteria.pagination.limit = limit + 1;
181+
criteria.pagination.marker = marker;
182+
183+
let snippets = storage.list(criteria.clone())?;
184+
let mut prev_needed = false;
185+
let (next_marker, snippets) = split_marker(snippets, limit);
186+
let prev_marker = if criteria.pagination.marker.is_some() && !snippets.is_empty() {
187+
// In order to generate Link entry for previous page we have no choice
188+
// but to issue the query one more time into opposite direction.
189+
criteria.pagination.direction = Direction::Asc;
190+
criteria.pagination.marker = Some(snippets[0].id.to_owned());
191+
let prev_snippets = storage.list(criteria)?;
192+
prev_needed = !prev_snippets.is_empty();
193+
194+
prev_snippets.get(limit).map(|m| m.id.to_owned())
195+
} else {
196+
None
197+
};
198+
199+
let mut headers_map = HeaderMap::new();
200+
headers_map.add_raw(
201+
"Link",
202+
create_link_header(origin, next_marker, prev_marker, prev_needed),
203+
);
204+
205+
Ok(WithHttpHeaders(headers_map, Some(Output(snippets))))
206+
}
207+
80208
#[derive(Deserialize)]
81209
#[serde(deny_unknown_fields)]
82210
pub struct ImportSnippet {

src/storage/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ mod models;
33
mod sql;
44

55
pub use errors::StorageError;
6-
pub use models::{Changeset, DateTime, Direction, ListSnippetsQuery, Snippet};
6+
pub use models::{Changeset, DateTime, Direction, ListSnippetsQuery, Pagination, Snippet};
77
pub use sql::SqlStorage;
88

99
/// CRUD interface for storing/loading snippets from a persistent storage.

src/storage/models.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ use std::iter;
33
use rand::Rng;
44
use serde::{ser::SerializeStruct, Serialize, Serializer};
55

6-
const DEFAULT_LIMIT_SIZE: usize = 20;
76
const DEFAULT_SLUG_LENGTH: usize = 8;
87

98
pub type DateTime = chrono::DateTime<chrono::Utc>;
@@ -82,7 +81,7 @@ impl Serialize for Snippet {
8281
}
8382
}
8483

85-
#[derive(Debug)]
84+
#[derive(Debug, Clone)]
8685
pub enum Direction {
8786
/// From older to newer snippets.
8887
#[allow(dead_code)]
@@ -91,7 +90,7 @@ pub enum Direction {
9190
Desc,
9291
}
9392

94-
#[derive(Debug)]
93+
#[derive(Debug, Clone)]
9594
pub struct Pagination {
9695
/// Pagination direction.
9796
pub direction: Direction,
@@ -104,17 +103,21 @@ pub struct Pagination {
104103
pub marker: Option<String>,
105104
}
106105

106+
impl Pagination {
107+
pub const DEFAULT_LIMIT_SIZE: usize = 20;
108+
}
109+
107110
impl Default for Pagination {
108111
fn default() -> Self {
109112
Pagination {
110113
direction: Direction::Desc,
111-
limit: DEFAULT_LIMIT_SIZE,
114+
limit: Self::DEFAULT_LIMIT_SIZE,
112115
marker: None,
113116
}
114117
}
115118
}
116119

117-
#[derive(Debug, Default)]
120+
#[derive(Debug, Default, Clone)]
118121
pub struct ListSnippetsQuery {
119122
/// If set, only the snippets with the specified title will be returned.
120123
pub title: Option<String>,

src/web.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ mod content;
33
mod tracing;
44

55
pub use crate::web::auth::{AuthValidator, BearerAuth, JwtValidator, User};
6-
pub use crate::web::content::{Input, NegotiatedContentType, Output};
6+
pub use crate::web::content::{
7+
Input, NegotiatedContentType, Output, PaginationLimit, WithHttpHeaders,
8+
};
79
pub use crate::web::tracing::{RequestId, RequestIdHeader, RequestSpan};

src/web/auth/jwt.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ impl AuthValidator for JwtValidator {
130130

131131
let key = self
132132
.jwks
133-
.find(&key_id)
133+
.find(key_id)
134134
.filter(|key| key.alg == header.alg && key.r#use == "sig")
135135
.ok_or_else(|| {
136136
Error::Configuration(format!("Signing key {:?} can't be found", key_id))
@@ -141,7 +141,7 @@ impl AuthValidator for JwtValidator {
141141
alg => return Err(Error::Input(format!("Unsupported algorithm: {:?}", alg))),
142142
};
143143

144-
match jsonwebtoken::decode::<Claims>(&token, &key, &self.validation) {
144+
match jsonwebtoken::decode::<Claims>(token, &key, &self.validation) {
145145
Ok(data) => Ok(User::Authenticated {
146146
name: data.claims.sub,
147147
permissions: data.claims.permissions,

0 commit comments

Comments
 (0)