+364 -55 +/-6 browse
1 | diff --git a/Cargo.lock b/Cargo.lock |
2 | index 8ceffe9..cf366b1 100644 |
3 | --- a/Cargo.lock |
4 | +++ b/Cargo.lock |
5 | @@ -1975,19 +1975,24 @@ dependencies = [ |
6 | "assert-json-diff", |
7 | "async-trait", |
8 | "axum", |
9 | + "axum-extra", |
10 | "bcrypt", |
11 | "config", |
12 | "http", |
13 | + "hyper", |
14 | "lazy_static", |
15 | "log", |
16 | "mailpot", |
17 | + "mailpot-tests", |
18 | "mailpot-web", |
19 | "reqwest", |
20 | "serde", |
21 | "serde_json", |
22 | "stderrlog", |
23 | + "tempfile", |
24 | "thiserror", |
25 | "tokio", |
26 | + "tower", |
27 | "tower-http 0.4.0", |
28 | ] |
29 | |
30 | diff --git a/mailpot-tests/for_testing.db b/mailpot-tests/for_testing.db |
31 | new file mode 100644 |
32 | index 0000000..70ccedb |
33 | Binary files /dev/null and b/mailpot-tests/for_testing.db differ |
34 | diff --git a/rest-http/Cargo.toml b/rest-http/Cargo.toml |
35 | index b237918..e79f615 100644 |
36 | --- a/rest-http/Cargo.toml |
37 | +++ b/rest-http/Cargo.toml |
38 | @@ -18,6 +18,7 @@ path = "src/main.rs" |
39 | [dependencies] |
40 | async-trait = "0.1" |
41 | axum = { version = "0.6", features = ["headers"] } |
42 | + axum-extra = { version = "^0.7", features = ["typed-routing"] } |
43 | #jsonwebtoken = "8.3" |
44 | bcrypt = "0.14" |
45 | config = "0.13" |
46 | @@ -41,4 +42,8 @@ tower-http = { version = "0.4", features = [ |
47 | |
48 | [dev-dependencies] |
49 | assert-json-diff = "2" |
50 | + hyper = { version = "0.14" } |
51 | + mailpot-tests = { version = "^0.1", path = "../mailpot-tests" } |
52 | reqwest = { version = "0.11", features = ["json"] } |
53 | + tempfile = "3.3" |
54 | + tower = { version = "^0.4" } |
55 | diff --git a/rest-http/src/lib.rs b/rest-http/src/lib.rs |
56 | index 6bc9fc6..3dd161a 100644 |
57 | --- a/rest-http/src/lib.rs |
58 | +++ b/rest-http/src/lib.rs |
59 | @@ -26,3 +26,26 @@ pub use mailpot::{models::*, Configuration, Connection}; |
60 | pub mod errors; |
61 | pub mod routes; |
62 | pub mod settings; |
63 | + |
64 | + use tower_http::{ |
65 | + compression::CompressionLayer, cors::CorsLayer, propagate_header::PropagateHeaderLayer, |
66 | + sensitive_headers::SetSensitiveHeadersLayer, |
67 | + }; |
68 | + |
69 | + pub fn create_app(conf: Arc<Configuration>) -> Router { |
70 | + Router::new() |
71 | + .with_state(conf.clone()) |
72 | + .merge(Router::new().nest("/v1", Router::new().merge(routes::list::create_route(conf)))) |
73 | + .layer(SetSensitiveHeadersLayer::new(std::iter::once( |
74 | + header::AUTHORIZATION, |
75 | + ))) |
76 | + // Compress responses |
77 | + .layer(CompressionLayer::new()) |
78 | + // Propagate `X-Request-Id`s from requests to responses |
79 | + .layer(PropagateHeaderLayer::new(header::HeaderName::from_static( |
80 | + "x-request-id", |
81 | + ))) |
82 | + // CORS configuration. This should probably be more restrictive in |
83 | + // production. |
84 | + .layer(CorsLayer::permissive()) |
85 | + } |
86 | diff --git a/rest-http/src/main.rs b/rest-http/src/main.rs |
87 | index c86a5ef..180de46 100644 |
88 | --- a/rest-http/src/main.rs |
89 | +++ b/rest-http/src/main.rs |
90 | @@ -1,25 +1,9 @@ |
91 | use mailpot_http::{settings::SETTINGS, *}; |
92 | - use tower_http::{ |
93 | - compression::CompressionLayer, cors::CorsLayer, propagate_header::PropagateHeaderLayer, |
94 | - sensitive_headers::SetSensitiveHeadersLayer, |
95 | - }; |
96 | |
97 | - use crate::routes; |
98 | + use crate::create_app; |
99 | |
100 | #[tokio::main] |
101 | async fn main() { |
102 | - let app = create_app().await; |
103 | - |
104 | - let port = SETTINGS.server.port; |
105 | - let address = SocketAddr::from(([127, 0, 0, 1], port)); |
106 | - |
107 | - info!("Server listening on {}", &address); |
108 | - axum::Server::bind(&address) |
109 | - .serve(app.into_make_service()) |
110 | - .await |
111 | - .expect("Failed to start server"); |
112 | - } |
113 | - pub async fn create_app() -> Router { |
114 | let config_path = std::env::args() |
115 | .nth(1) |
116 | .expect("Expected configuration file path as first argument."); |
117 | @@ -31,20 +15,14 @@ pub async fn create_app() -> Router { |
118 | .init() |
119 | .unwrap(); |
120 | let conf = Arc::new(Configuration::from_file(config_path).unwrap()); |
121 | + let app = create_app(conf); |
122 | |
123 | - Router::new() |
124 | - .with_state(conf.clone()) |
125 | - .merge(Router::new().nest("/v1", Router::new().merge(routes::list::create_route(conf)))) |
126 | - .layer(SetSensitiveHeadersLayer::new(std::iter::once( |
127 | - header::AUTHORIZATION, |
128 | - ))) |
129 | - // Compress responses |
130 | - .layer(CompressionLayer::new()) |
131 | - // Propagate `X-Request-Id`s from requests to responses |
132 | - .layer(PropagateHeaderLayer::new(header::HeaderName::from_static( |
133 | - "x-request-id", |
134 | - ))) |
135 | - // CORS configuration. This should probably be more restrictive in |
136 | - // production. |
137 | - .layer(CorsLayer::permissive()) |
138 | + let port = SETTINGS.server.port; |
139 | + let address = SocketAddr::from(([127, 0, 0, 1], port)); |
140 | + |
141 | + info!("Server listening on {}", &address); |
142 | + axum::Server::bind(&address) |
143 | + .serve(app.into_make_service()) |
144 | + .await |
145 | + .expect("Failed to start server"); |
146 | } |
147 | diff --git a/rest-http/src/routes/list.rs b/rest-http/src/routes/list.rs |
148 | index 6aa7f7d..c9947f0 100644 |
149 | --- a/rest-http/src/routes/list.rs |
150 | +++ b/rest-http/src/routes/list.rs |
151 | @@ -1,28 +1,48 @@ |
152 | use std::sync::Arc; |
153 | |
154 | pub use axum::extract::{Path, Query, State}; |
155 | - use axum::{ |
156 | - http::StatusCode, |
157 | - routing::{get, post}, |
158 | - Json, Router, |
159 | - }; |
160 | - use mailpot_web::{typed_paths::*, ResponseError, RouterExt}; |
161 | + use axum::{http::StatusCode, Json, Router}; |
162 | + use mailpot_web::{typed_paths::*, ResponseError, RouterExt, TypedPath}; |
163 | use serde::{Deserialize, Serialize}; |
164 | |
165 | use crate::*; |
166 | |
167 | + #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
168 | + #[typed_path("/list/")] |
169 | + pub struct ListsPath; |
170 | + |
171 | + #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
172 | + #[typed_path("/list/:id/owner/")] |
173 | + pub struct ListOwnerPath(pub ListPathIdentifier); |
174 | + |
175 | + #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
176 | + #[typed_path("/list/:id/subscription/")] |
177 | + pub struct ListSubscriptionPath(pub ListPathIdentifier); |
178 | + |
179 | pub fn create_route(conf: Arc<Configuration>) -> Router { |
180 | Router::new() |
181 | - .route("/list/", get(all_lists)) |
182 | - .route("/list/", post(post_list)) |
183 | + .typed_get(all_lists) |
184 | + .typed_post(new_list) |
185 | .typed_get(get_list) |
186 | + .typed_post({ |
187 | + move |_: ListPath| async move { |
188 | + Err::<(), ResponseError>(mailpot_web::ResponseError::new( |
189 | + "Invalid method".to_string(), |
190 | + StatusCode::BAD_REQUEST, |
191 | + )) |
192 | + } |
193 | + }) |
194 | + .typed_get(get_list_owner) |
195 | + .typed_post(new_list_owner) |
196 | + .typed_get(get_list_subs) |
197 | + .typed_post(new_list_sub) |
198 | .with_state(conf) |
199 | } |
200 | |
201 | async fn get_list( |
202 | ListPath(id): ListPath, |
203 | State(state): State<Arc<Configuration>>, |
204 | - ) -> Result<Json<DbVal<MailingList>>, ResponseError> { |
205 | + ) -> Result<Json<MailingList>, ResponseError> { |
206 | let db = Connection::open_db(Configuration::clone(&state))?; |
207 | let Some(list) = (match id { |
208 | ListPathIdentifier::Pk(id) => db.list(id)?, |
209 | @@ -33,10 +53,11 @@ async fn get_list( |
210 | StatusCode::NOT_FOUND, |
211 | )); |
212 | }; |
213 | - Ok(Json(list)) |
214 | + Ok(Json(list.into_inner())) |
215 | } |
216 | |
217 | async fn all_lists( |
218 | + _: ListsPath, |
219 | Query(GetRequest { |
220 | filter: _, |
221 | count, |
222 | @@ -61,7 +82,12 @@ async fn all_lists( |
223 | })); |
224 | }; |
225 | let offset = page * count; |
226 | - let res: Vec<_> = lists_values.into_iter().skip(offset).take(count).collect(); |
227 | + let res: Vec<_> = lists_values |
228 | + .into_iter() |
229 | + .skip(offset) |
230 | + .take(count) |
231 | + .map(DbVal::into_inner) |
232 | + .collect(); |
233 | |
234 | Ok(Json(GetResponse { |
235 | total: res.len(), |
236 | @@ -70,18 +96,16 @@ async fn all_lists( |
237 | })) |
238 | } |
239 | |
240 | - async fn post_list( |
241 | - State(state): State<Arc<Configuration>>, |
242 | - Json(_body): Json<GetRequest>, |
243 | + async fn new_list( |
244 | + _: ListsPath, |
245 | + State(_state): State<Arc<Configuration>>, |
246 | + //Json(_body): Json<GetRequest>, |
247 | ) -> Result<Json<()>, ResponseError> { |
248 | - let _db = Connection::open_db(Configuration::clone(&state))?; |
249 | - // let password_hash = list::hash_password(body.password).await?; |
250 | - // let list = list::new(body.name, body.email, password_hash); |
251 | - // let list = list::create(list).await?; |
252 | - // let res = Publiclist::from(list); |
253 | - // |
254 | - |
255 | - Ok(Json(())) |
256 | + // TODO create new list |
257 | + Err(mailpot_web::ResponseError::new( |
258 | + "Not allowed".to_string(), |
259 | + StatusCode::UNAUTHORIZED, |
260 | + )) |
261 | } |
262 | |
263 | #[derive(Debug, Serialize, Deserialize)] |
264 | @@ -101,7 +125,281 @@ struct GetRequest { |
265 | |
266 | #[derive(Debug, Serialize, Deserialize)] |
267 | struct GetResponse { |
268 | - entries: Vec<DbVal<MailingList>>, |
269 | + entries: Vec<MailingList>, |
270 | total: usize, |
271 | start: usize, |
272 | } |
273 | + |
274 | + async fn get_list_owner( |
275 | + ListOwnerPath(id): ListOwnerPath, |
276 | + State(state): State<Arc<Configuration>>, |
277 | + ) -> Result<Json<Vec<ListOwner>>, ResponseError> { |
278 | + let db = Connection::open_db(Configuration::clone(&state))?; |
279 | + let owners = match id { |
280 | + ListPathIdentifier::Pk(id) => db.list_owners(id)?, |
281 | + ListPathIdentifier::Id(id) => { |
282 | + if let Some(owners) = db.list_by_id(id)?.map(|l| db.list_owners(l.pk())) { |
283 | + owners? |
284 | + } else { |
285 | + return Err(mailpot_web::ResponseError::new( |
286 | + "Not found".to_string(), |
287 | + StatusCode::NOT_FOUND, |
288 | + )); |
289 | + } |
290 | + } |
291 | + }; |
292 | + Ok(Json(owners.into_iter().map(DbVal::into_inner).collect())) |
293 | + } |
294 | + |
295 | + async fn new_list_owner( |
296 | + ListOwnerPath(_id): ListOwnerPath, |
297 | + State(_state): State<Arc<Configuration>>, |
298 | + //Json(_body): Json<GetRequest>, |
299 | + ) -> Result<Json<Vec<ListOwner>>, ResponseError> { |
300 | + Err(mailpot_web::ResponseError::new( |
301 | + "Not allowed".to_string(), |
302 | + StatusCode::UNAUTHORIZED, |
303 | + )) |
304 | + } |
305 | + |
306 | + async fn get_list_subs( |
307 | + ListSubscriptionPath(id): ListSubscriptionPath, |
308 | + State(state): State<Arc<Configuration>>, |
309 | + ) -> Result<Json<Vec<ListSubscription>>, ResponseError> { |
310 | + let db = Connection::open_db(Configuration::clone(&state))?; |
311 | + let subs = match id { |
312 | + ListPathIdentifier::Pk(id) => db.list_subscriptions(id)?, |
313 | + ListPathIdentifier::Id(id) => { |
314 | + if let Some(v) = db.list_by_id(id)?.map(|l| db.list_subscriptions(l.pk())) { |
315 | + v? |
316 | + } else { |
317 | + return Err(mailpot_web::ResponseError::new( |
318 | + "Not found".to_string(), |
319 | + StatusCode::NOT_FOUND, |
320 | + )); |
321 | + } |
322 | + } |
323 | + }; |
324 | + Ok(Json(subs.into_iter().map(DbVal::into_inner).collect())) |
325 | + } |
326 | + |
327 | + async fn new_list_sub( |
328 | + ListSubscriptionPath(_id): ListSubscriptionPath, |
329 | + State(_state): State<Arc<Configuration>>, |
330 | + //Json(_body): Json<GetRequest>, |
331 | + ) -> Result<Json<ListSubscription>, ResponseError> { |
332 | + Err(mailpot_web::ResponseError::new( |
333 | + "Not allowed".to_string(), |
334 | + StatusCode::UNAUTHORIZED, |
335 | + )) |
336 | + } |
337 | + |
338 | + #[cfg(test)] |
339 | + mod tests { |
340 | + |
341 | + use axum::{ |
342 | + body::Body, |
343 | + http::{method::Method, Request, StatusCode}, |
344 | + }; |
345 | + use mailpot::{models::*, Configuration, Connection, SendMail}; |
346 | + use mailpot_tests::init_stderr_logging; |
347 | + use serde_json::json; |
348 | + use tempfile::TempDir; |
349 | + use tower::ServiceExt; // for `oneshot` and `ready` |
350 | + |
351 | + use super::*; |
352 | + |
353 | + #[tokio::test] |
354 | + async fn test_list_router() { |
355 | + init_stderr_logging(); |
356 | + |
357 | + let tmp_dir = TempDir::new().unwrap(); |
358 | + |
359 | + let db_path = tmp_dir.path().join("mpot.db"); |
360 | + std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap(); |
361 | + let config = Configuration { |
362 | + send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()), |
363 | + db_path, |
364 | + data_path: tmp_dir.path().to_path_buf(), |
365 | + administrators: vec![], |
366 | + }; |
367 | + |
368 | + let db = Connection::open_db(config.clone()).unwrap().trusted(); |
369 | + assert!(!db.lists().unwrap().is_empty()); |
370 | + let foo_chat = MailingList { |
371 | + pk: 1, |
372 | + name: "foobar chat".into(), |
373 | + id: "foo-chat".into(), |
374 | + address: "foo-chat@example.com".into(), |
375 | + description: None, |
376 | + archive_url: None, |
377 | + }; |
378 | + drop(db); |
379 | + |
380 | + let config = Arc::new(config); |
381 | + |
382 | + // ------------------------------------------------------------ |
383 | + // all_lists() get total |
384 | + |
385 | + let response = crate::create_app(config.clone()) |
386 | + .oneshot( |
387 | + Request::builder() |
388 | + .uri("/v1/list/") |
389 | + .body(Body::empty()) |
390 | + .unwrap(), |
391 | + ) |
392 | + .await |
393 | + .unwrap(); |
394 | + |
395 | + assert_eq!(response.status(), StatusCode::OK); |
396 | + |
397 | + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); |
398 | + let r: GetResponse = serde_json::from_slice(&body).unwrap(); |
399 | + |
400 | + assert_eq!(&r.entries, &[]); |
401 | + assert_eq!(r.total, 1); |
402 | + assert_eq!(r.start, 0); |
403 | + |
404 | + // ------------------------------------------------------------ |
405 | + // all_lists() with count |
406 | + |
407 | + let response = crate::create_app(config.clone()) |
408 | + .oneshot( |
409 | + Request::builder() |
410 | + .uri("/v1/list/?count=20") |
411 | + .body(Body::empty()) |
412 | + .unwrap(), |
413 | + ) |
414 | + .await |
415 | + .unwrap(); |
416 | + assert_eq!(response.status(), StatusCode::OK); |
417 | + |
418 | + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); |
419 | + let r: GetResponse = serde_json::from_slice(&body).unwrap(); |
420 | + |
421 | + assert_eq!(&r.entries, &[foo_chat.clone()]); |
422 | + assert_eq!(r.total, 1); |
423 | + assert_eq!(r.start, 0); |
424 | + |
425 | + // ------------------------------------------------------------ |
426 | + // new_list() |
427 | + |
428 | + let response = crate::create_app(config.clone()) |
429 | + .oneshot( |
430 | + Request::builder() |
431 | + .uri("/v1/list/") |
432 | + .header("Content-Type", "application/json") |
433 | + .method(Method::POST) |
434 | + .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap())) |
435 | + .unwrap(), |
436 | + ) |
437 | + .await |
438 | + .unwrap(); |
439 | + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); |
440 | + |
441 | + // ------------------------------------------------------------ |
442 | + // get_list() |
443 | + |
444 | + let response = crate::create_app(config.clone()) |
445 | + .oneshot( |
446 | + Request::builder() |
447 | + .uri("/v1/list/1/") |
448 | + .header("Content-Type", "application/json") |
449 | + .method(Method::GET) |
450 | + .body(Body::empty()) |
451 | + .unwrap(), |
452 | + ) |
453 | + .await |
454 | + .unwrap(); |
455 | + assert_eq!(response.status(), StatusCode::OK); |
456 | + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); |
457 | + let r: MailingList = serde_json::from_slice(&body).unwrap(); |
458 | + assert_eq!(&r, &foo_chat); |
459 | + |
460 | + // ------------------------------------------------------------ |
461 | + // get_list_subs() |
462 | + |
463 | + let response = crate::create_app(config.clone()) |
464 | + .oneshot( |
465 | + Request::builder() |
466 | + .uri("/v1/list/1/subscription/") |
467 | + .header("Content-Type", "application/json") |
468 | + .method(Method::GET) |
469 | + .body(Body::empty()) |
470 | + .unwrap(), |
471 | + ) |
472 | + .await |
473 | + .unwrap(); |
474 | + assert_eq!(response.status(), StatusCode::OK); |
475 | + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); |
476 | + let r: Vec<ListSubscription> = serde_json::from_slice(&body).unwrap(); |
477 | + assert_eq!( |
478 | + &r, |
479 | + &[ListSubscription { |
480 | + pk: 1, |
481 | + list: 1, |
482 | + address: "user@example.com".to_string(), |
483 | + name: Some("Name".to_string()), |
484 | + account: None, |
485 | + enabled: true, |
486 | + verified: false, |
487 | + digest: false, |
488 | + hide_address: false, |
489 | + receive_duplicates: true, |
490 | + receive_own_posts: false, |
491 | + receive_confirmation: true |
492 | + }] |
493 | + ); |
494 | + |
495 | + // ------------------------------------------------------------ |
496 | + // new_list_sub() |
497 | + |
498 | + let response = crate::create_app(config.clone()) |
499 | + .oneshot( |
500 | + Request::builder() |
501 | + .uri("/v1/list/1/subscription/") |
502 | + .header("Content-Type", "application/json") |
503 | + .method(Method::POST) |
504 | + .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap())) |
505 | + .unwrap(), |
506 | + ) |
507 | + .await |
508 | + .unwrap(); |
509 | + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); |
510 | + |
511 | + // ------------------------------------------------------------ |
512 | + // get_list_owner() |
513 | + |
514 | + let response = crate::create_app(config.clone()) |
515 | + .oneshot( |
516 | + Request::builder() |
517 | + .uri("/v1/list/1/owner/") |
518 | + .header("Content-Type", "application/json") |
519 | + .method(Method::GET) |
520 | + .body(Body::empty()) |
521 | + .unwrap(), |
522 | + ) |
523 | + .await |
524 | + .unwrap(); |
525 | + assert_eq!(response.status(), StatusCode::OK); |
526 | + let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); |
527 | + let r: Vec<ListOwner> = serde_json::from_slice(&body).unwrap(); |
528 | + assert_eq!(&r, &[]); |
529 | + |
530 | + // ------------------------------------------------------------ |
531 | + // new_list_owner() |
532 | + |
533 | + let response = crate::create_app(config.clone()) |
534 | + .oneshot( |
535 | + Request::builder() |
536 | + .uri("/v1/list/1/owner/") |
537 | + .header("Content-Type", "application/json") |
538 | + .method(Method::POST) |
539 | + .body(Body::from(serde_json::to_vec(&json! {{}}).unwrap())) |
540 | + .unwrap(), |
541 | + ) |
542 | + .await |
543 | + .unwrap(); |
544 | + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); |
545 | + } |
546 | + } |