Skip to content

Commit 838129a

Browse files
committed
feat: [torrust#725] API. Add scrape filter to torrents endpoint
The torrents endppint allow getting a list of torrents provifing the infohashes: http://127.0.0.1:1212/api/v1/torrents?token=MyAccessToken&info_hash=9c38422213e30bff212b30c360d26f9a02136422&info_hash=2b66980093bc11806fab50cb3cb41835b95a0362 It's like the tracker "scrape" request. The response JSON is the same as the normal torrent list: ```json [ { "info_hash": "9c38422213e30bff212b30c360d26f9a02136422", "seeders": 1, "completed": 0, "leechers": 0 }, { "info_hash": "2b66980093bc11806fab50cb3cb41835b95a0362", "seeders": 1, "completed": 0, "leechers": 0 } ] ```
1 parent 4b24256 commit 838129a

File tree

3 files changed

+162
-26
lines changed

3 files changed

+162
-26
lines changed

src/core/services/torrent.rs

+29-7
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ pub async fn get_torrent_info(tracker: Arc<Tracker>, info_hash: &InfoHash) -> Op
115115
}
116116

117117
/// It returns all the information the tracker has about multiple torrents in a [`BasicInfo`] struct, excluding the peer list.
118-
pub async fn get_torrents(tracker: Arc<Tracker>, pagination: &Pagination) -> Vec<BasicInfo> {
118+
pub async fn get_torrents_page(tracker: Arc<Tracker>, pagination: &Pagination) -> Vec<BasicInfo> {
119119
let db = tracker.torrents.get_torrents().await;
120120

121121
let mut basic_infos: Vec<BasicInfo> = vec![];
@@ -134,6 +134,28 @@ pub async fn get_torrents(tracker: Arc<Tracker>, pagination: &Pagination) -> Vec
134134
basic_infos
135135
}
136136

137+
/// It returns all the information the tracker has about multiple torrents in a [`BasicInfo`] struct, excluding the peer list.
138+
pub async fn get_torrents(tracker: Arc<Tracker>, info_hashes: &[InfoHash]) -> Vec<BasicInfo> {
139+
let db = tracker.torrents.get_torrents().await;
140+
141+
let mut basic_infos: Vec<BasicInfo> = vec![];
142+
143+
for info_hash in info_hashes {
144+
if let Some(entry) = db.get(info_hash) {
145+
let (seeders, completed, leechers) = entry.get_stats();
146+
147+
basic_infos.push(BasicInfo {
148+
info_hash: *info_hash,
149+
seeders: u64::from(seeders),
150+
completed: u64::from(completed),
151+
leechers: u64::from(leechers),
152+
});
153+
}
154+
}
155+
156+
basic_infos
157+
}
158+
137159
#[cfg(test)]
138160
mod tests {
139161
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
@@ -219,7 +241,7 @@ mod tests {
219241
use torrust_tracker_test_helpers::configuration;
220242

221243
use crate::core::services::torrent::tests::sample_peer;
222-
use crate::core::services::torrent::{get_torrents, BasicInfo, Pagination};
244+
use crate::core::services::torrent::{get_torrents_page, BasicInfo, Pagination};
223245
use crate::core::services::tracker_factory;
224246
use crate::shared::bit_torrent::info_hash::InfoHash;
225247

@@ -231,7 +253,7 @@ mod tests {
231253
async fn should_return_an_empty_result_if_the_tracker_does_not_have_any_torrent() {
232254
let tracker = Arc::new(tracker_factory(&tracker_configuration()));
233255

234-
let torrents = get_torrents(tracker.clone(), &Pagination::default()).await;
256+
let torrents = get_torrents_page(tracker.clone(), &Pagination::default()).await;
235257

236258
assert_eq!(torrents, vec![]);
237259
}
@@ -247,7 +269,7 @@ mod tests {
247269
.update_torrent_with_peer_and_get_stats(&info_hash, &sample_peer())
248270
.await;
249271

250-
let torrents = get_torrents(tracker.clone(), &Pagination::default()).await;
272+
let torrents = get_torrents_page(tracker.clone(), &Pagination::default()).await;
251273

252274
assert_eq!(
253275
torrents,
@@ -279,7 +301,7 @@ mod tests {
279301
let offset = 0;
280302
let limit = 1;
281303

282-
let torrents = get_torrents(tracker.clone(), &Pagination::new(offset, limit)).await;
304+
let torrents = get_torrents_page(tracker.clone(), &Pagination::new(offset, limit)).await;
283305

284306
assert_eq!(torrents.len(), 1);
285307
}
@@ -303,7 +325,7 @@ mod tests {
303325
let offset = 1;
304326
let limit = 4000;
305327

306-
let torrents = get_torrents(tracker.clone(), &Pagination::new(offset, limit)).await;
328+
let torrents = get_torrents_page(tracker.clone(), &Pagination::new(offset, limit)).await;
307329

308330
assert_eq!(torrents.len(), 1);
309331
assert_eq!(
@@ -333,7 +355,7 @@ mod tests {
333355
.update_torrent_with_peer_and_get_stats(&info_hash2, &sample_peer())
334356
.await;
335357

336-
let torrents = get_torrents(tracker.clone(), &Pagination::default()).await;
358+
let torrents = get_torrents_page(tracker.clone(), &Pagination::default()).await;
337359

338360
assert_eq!(
339361
torrents,

src/servers/apis/v1/context/torrent/handlers.rs

+69-18
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ use std::fmt;
44
use std::str::FromStr;
55
use std::sync::Arc;
66

7-
use axum::extract::{Path, Query, State};
8-
use axum::response::{IntoResponse, Json, Response};
7+
use axum::extract::{Path, State};
8+
use axum::response::{IntoResponse, Response};
9+
use axum_extra::extract::Query;
910
use log::debug;
1011
use serde::{de, Deserialize, Deserializer};
12+
use thiserror::Error;
1113

12-
use super::resources::torrent::ListItem;
1314
use super::responses::{torrent_info_response, torrent_list_response, torrent_not_known_response};
14-
use crate::core::services::torrent::{get_torrent_info, get_torrents, Pagination};
15+
use crate::core::services::torrent::{get_torrent_info, get_torrents, get_torrents_page, Pagination};
1516
use crate::core::Tracker;
1617
use crate::servers::apis::v1::responses::invalid_info_hash_param_response;
1718
use crate::servers::apis::InfoHashParam;
@@ -36,16 +37,36 @@ pub async fn get_torrent_handler(State(tracker): State<Arc<Tracker>>, Path(info_
3637
}
3738
}
3839

39-
/// A container for the optional URL query pagination parameters:
40-
/// `offset` and `limit`.
40+
/// A container for the URL query parameters.
41+
///
42+
/// Pagination: `offset` and `limit`.
43+
/// Array of infohashes: `info_hash`.
44+
///
45+
/// You can either get all torrents with pagination or get a list of torrents
46+
/// providing a list of infohashes. For example:
47+
///
48+
/// First page of torrents:
49+
///
50+
/// <http://127.0.0.1:1212/api/v1/torrents?token=MyAccessToken>
51+
///
52+
///
53+
/// Only two torrents:
54+
///
55+
/// <http://127.0.0.1:1212/api/v1/torrents?token=MyAccessToken&info_hash=9c38422213e30bff212b30c360d26f9a02136422&info_hash=2b66980093bc11806fab50cb3cb41835b95a0362>
56+
///
57+
///
58+
/// NOTICE: Pagination is ignored if array of infohashes is provided.
4159
#[derive(Deserialize, Debug)]
42-
pub struct PaginationParams {
60+
pub struct QueryParams {
4361
/// The offset of the first page to return. Starts at 0.
4462
#[serde(default, deserialize_with = "empty_string_as_none")]
4563
pub offset: Option<u32>,
46-
/// The maximum number of items to return per page
64+
/// The maximum number of items to return per page.
4765
#[serde(default, deserialize_with = "empty_string_as_none")]
4866
pub limit: Option<u32>,
67+
/// A list of infohashes to retrieve.
68+
#[serde(default, rename = "info_hash")]
69+
pub info_hashes: Vec<String>,
4970
}
5071

5172
/// It handles the request to get a list of torrents.
@@ -56,19 +77,49 @@ pub struct PaginationParams {
5677
///
5778
/// Refer to the [API endpoint documentation](crate::servers::apis::v1::context::torrent#list-torrents)
5879
/// for more information about this endpoint.
59-
pub async fn get_torrents_handler(
60-
State(tracker): State<Arc<Tracker>>,
61-
pagination: Query<PaginationParams>,
62-
) -> Json<Vec<ListItem>> {
80+
pub async fn get_torrents_handler(State(tracker): State<Arc<Tracker>>, pagination: Query<QueryParams>) -> Response {
6381
debug!("pagination: {:?}", pagination);
6482

65-
torrent_list_response(
66-
&get_torrents(
67-
tracker.clone(),
68-
&Pagination::new_with_options(pagination.0.offset, pagination.0.limit),
83+
if pagination.0.info_hashes.is_empty() {
84+
torrent_list_response(
85+
&get_torrents_page(
86+
tracker.clone(),
87+
&Pagination::new_with_options(pagination.0.offset, pagination.0.limit),
88+
)
89+
.await,
6990
)
70-
.await,
71-
)
91+
.into_response()
92+
} else {
93+
match parse_info_hashes(pagination.0.info_hashes) {
94+
Ok(info_hashes) => torrent_list_response(&get_torrents(tracker.clone(), &info_hashes).await).into_response(),
95+
Err(err) => match err {
96+
QueryParamError::InvalidInfoHash { info_hash } => invalid_info_hash_param_response(&info_hash),
97+
},
98+
}
99+
}
100+
}
101+
102+
#[derive(Error, Debug)]
103+
pub enum QueryParamError {
104+
#[error("invalid infohash {info_hash}")]
105+
InvalidInfoHash { info_hash: String },
106+
}
107+
108+
fn parse_info_hashes(info_hashes_str: Vec<String>) -> Result<Vec<InfoHash>, QueryParamError> {
109+
let mut info_hashes: Vec<InfoHash> = Vec::new();
110+
111+
for info_hash_str in info_hashes_str {
112+
match InfoHash::from_str(&info_hash_str) {
113+
Ok(info_hash) => info_hashes.push(info_hash),
114+
Err(_err) => {
115+
return Err(QueryParamError::InvalidInfoHash {
116+
info_hash: info_hash_str,
117+
})
118+
}
119+
}
120+
}
121+
122+
Ok(info_hashes)
72123
}
73124

74125
/// Serde deserialization decorator to map empty Strings to None,

tests/servers/api/v1/contract/context/torrent.rs

+64-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use crate::servers::api::v1::contract::fixtures::{
1919
use crate::servers::api::Started;
2020

2121
#[tokio::test]
22-
async fn should_allow_getting_torrents() {
22+
async fn should_allow_getting_all_torrents() {
2323
let env = Started::new(&configuration::ephemeral().into()).await;
2424

2525
let info_hash = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap();
@@ -100,6 +100,48 @@ async fn should_allow_the_torrents_result_pagination() {
100100
env.stop().await;
101101
}
102102

103+
#[tokio::test]
104+
async fn should_allow_getting_a_list_of_torrents_providing_infohashes() {
105+
let env = Started::new(&configuration::ephemeral().into()).await;
106+
107+
let info_hash_1 = InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(); // DevSkim: ignore DS173237
108+
let info_hash_2 = InfoHash::from_str("0b3aea4adc213ce32295be85d3883a63bca25446").unwrap(); // DevSkim: ignore DS173237
109+
110+
env.add_torrent_peer(&info_hash_1, &PeerBuilder::default().into()).await;
111+
env.add_torrent_peer(&info_hash_2, &PeerBuilder::default().into()).await;
112+
113+
let response = Client::new(env.get_connection_info())
114+
.get_torrents(Query::params(
115+
[
116+
QueryParam::new("info_hash", "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d"), // DevSkim: ignore DS173237
117+
QueryParam::new("info_hash", "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d"), // DevSkim: ignore DS173237
118+
]
119+
.to_vec(),
120+
))
121+
.await;
122+
123+
assert_torrent_list(
124+
response,
125+
vec![
126+
torrent::ListItem {
127+
info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), // DevSkim: ignore DS173237
128+
seeders: 1,
129+
completed: 0,
130+
leechers: 0,
131+
},
132+
torrent::ListItem {
133+
info_hash: "9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d".to_string(), // DevSkim: ignore DS173237
134+
seeders: 1,
135+
completed: 0,
136+
leechers: 0,
137+
},
138+
],
139+
)
140+
.await;
141+
142+
env.stop().await;
143+
}
144+
103145
#[tokio::test]
104146
async fn should_fail_getting_torrents_when_the_offset_query_parameter_cannot_be_parsed() {
105147
let env = Started::new(&configuration::ephemeral().into()).await;
@@ -134,6 +176,27 @@ async fn should_fail_getting_torrents_when_the_limit_query_parameter_cannot_be_p
134176
env.stop().await;
135177
}
136178

179+
#[tokio::test]
180+
async fn should_fail_getting_torrents_when_the_info_hash_parameter_is_invalid() {
181+
let env = Started::new(&configuration::ephemeral().into()).await;
182+
183+
let invalid_info_hashes = [" ", "-1", "1.1", "INVALID INFO_HASH"];
184+
185+
for invalid_info_hash in &invalid_info_hashes {
186+
let response = Client::new(env.get_connection_info())
187+
.get_torrents(Query::params([QueryParam::new("info_hash", invalid_info_hash)].to_vec()))
188+
.await;
189+
190+
assert_bad_request(
191+
response,
192+
&format!("Invalid URL: invalid infohash param: string \"{invalid_info_hash}\", expected a 40 character long string"),
193+
)
194+
.await;
195+
}
196+
197+
env.stop().await;
198+
}
199+
137200
#[tokio::test]
138201
async fn should_not_allow_getting_torrents_for_unauthenticated_users() {
139202
let env = Started::new(&configuration::ephemeral().into()).await;

0 commit comments

Comments
 (0)