An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(relay): address PR review feedback on MM-71

- Bump tower workspace dep to 0.5 to eliminate duplicate tower 0.4/0.5 in build graph
- Add MethodNotImplemented to status_code_mapping test in error.rs
- Add xrpc_delete_returns_405 test (documents that non-GET/POST is intentionally 405)
- Add xrpc_response_has_json_content_type test at the router level
- shutdown_signal() now returns anyhow::Result<()> — propagates instead of panicking
- Log axum server errors via tracing::error! before propagating
- Fix "shadow" comment: Axum uses priority, not shadowing
- Fix ErrorCode enum doc to acknowledge PascalCase exceptions
- Fix tracing::error! to use structured error = %err field
- Fix body test to capture status before consuming body with into_body()
- Narrow "Functional Core (pure — no I/O)" to "I/O-free"

authored by

Malpercio and committed by
Tangled
dea05bc2 21c7560f

+68 -52
+2 -37
Cargo.lock
··· 112 112 "serde_urlencoded", 113 113 "sync_wrapper", 114 114 "tokio", 115 - "tower 0.5.3", 115 + "tower", 116 116 "tower-layer", 117 117 "tower-service", 118 118 "tracing", ··· 564 564 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 565 565 566 566 [[package]] 567 - name = "pin-project" 568 - version = "1.1.11" 569 - source = "registry+https://github.com/rust-lang/crates.io-index" 570 - checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" 571 - dependencies = [ 572 - "pin-project-internal", 573 - ] 574 - 575 - [[package]] 576 - name = "pin-project-internal" 577 - version = "1.1.11" 578 - source = "registry+https://github.com/rust-lang/crates.io-index" 579 - checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" 580 - dependencies = [ 581 - "proc-macro2", 582 - "quote", 583 - "syn", 584 - ] 585 - 586 - [[package]] 587 567 name = "pin-project-lite" 588 568 version = "0.2.17" 589 569 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 665 645 "common", 666 646 "serde_json", 667 647 "tokio", 668 - "tower 0.4.13", 648 + "tower", 669 649 "tower-http", 670 650 "tracing", 671 651 "tracing-subscriber", ··· 961 941 version = "0.1.2" 962 942 source = "registry+https://github.com/rust-lang/crates.io-index" 963 943 checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 964 - 965 - [[package]] 966 - name = "tower" 967 - version = "0.4.13" 968 - source = "registry+https://github.com/rust-lang/crates.io-index" 969 - checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" 970 - dependencies = [ 971 - "futures-core", 972 - "futures-util", 973 - "pin-project", 974 - "pin-project-lite", 975 - "tower-layer", 976 - "tower-service", 977 - "tracing", 978 - ] 979 944 980 945 [[package]] 981 946 name = "tower"
+1 -1
Cargo.toml
··· 39 39 40 40 # HTTP middleware 41 41 tower-http = { version = "0.5", features = ["trace", "cors"] } 42 - tower = { version = "0.4", features = ["util"] } 42 + tower = { version = "0.5", features = ["util"] } 43 43 44 44 # ATProto (repo-engine) 45 45 # atrium-api = "0.22"
+6 -2
crates/common/src/error.rs
··· 5 5 6 6 /// Error codes for the provisioning API. 7 7 /// 8 - /// Serialized as SCREAMING_SNAKE_CASE strings in the JSON error envelope. 8 + /// Most variants serialize as SCREAMING_SNAKE_CASE. Exceptions use `#[serde(rename)]` 9 + /// when a specific wire format is required (e.g. `MethodNotImplemented` uses PascalCase 10 + /// to match the AT Protocol XRPC error format). 11 + /// 9 12 /// `#[non_exhaustive]` prevents external crates from writing exhaustive match 10 13 /// arms — new variants can be added in future waves without breaking callers. 11 14 #[non_exhaustive] ··· 123 126 (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() 124 127 } 125 128 Err(err) => { 126 - tracing::error!("failed to serialize ApiError: {err}"); 129 + tracing::error!(error = %err, "failed to serialize ApiError"); 127 130 ( 128 131 StatusCode::INTERNAL_SERVER_ERROR, 129 132 Json(serde_json::json!({ ··· 195 198 (ErrorCode::WeakPassword, 422), 196 199 (ErrorCode::RateLimited, 429), 197 200 (ErrorCode::ExportInProgress, 503), 201 + (ErrorCode::MethodNotImplemented, 501), 198 202 ]; 199 203 for (code, expected) in cases { 200 204 assert_eq!(code.status_code(), expected, "wrong status for {code:?}");
+43 -4
crates/relay/src/app.rs
··· 1 - // pattern: Functional Core (router construction is pure — no I/O) 1 + // pattern: Functional Core (router construction is I/O-free) 2 2 3 3 use std::sync::Arc; 4 4 ··· 11 11 /// Fields will grow as waves are implemented (MM-72 adds the DB pool, etc.). 12 12 #[derive(Clone)] 13 13 pub struct AppState { 14 - // Read by handlers from MM-73 onward; suppressed until then. 14 + // Read by handlers once XRPC endpoints are implemented; suppressed until then. 15 15 #[allow(dead_code)] 16 16 pub config: Arc<Config>, 17 17 } ··· 30 30 31 31 /// Catch-all XRPC handler — returns `MethodNotImplemented` for any unrecognised NSID. 32 32 /// 33 - /// Real XRPC endpoints (MM-73+) will register specific routes that shadow this catch-all 34 - /// for their own NSIDs. 33 + /// Axum gives static path segments priority over parameterised ones, so specific routes 34 + /// registered for individual NSIDs will match before this catch-all. 35 35 async fn xrpc_handler(Path(method): Path<String>) -> ApiError { 36 36 ApiError::new( 37 37 ErrorCode::MethodNotImplemented, ··· 96 96 assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); 97 97 } 98 98 99 + // XRPC only defines GET (queries) and POST (procedures); other methods are not part of 100 + // the protocol and correctly return 405. 101 + #[tokio::test] 102 + async fn xrpc_delete_returns_405() { 103 + let response = app(test_state()) 104 + .oneshot( 105 + Request::builder() 106 + .method("DELETE") 107 + .uri("/xrpc/com.example.unknownMethod") 108 + .body(Body::empty()) 109 + .unwrap(), 110 + ) 111 + .await 112 + .unwrap(); 113 + 114 + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); 115 + } 116 + 117 + #[tokio::test] 118 + async fn xrpc_response_has_json_content_type() { 119 + let response = app(test_state()) 120 + .oneshot( 121 + Request::builder() 122 + .uri("/xrpc/com.example.unknownMethod") 123 + .body(Body::empty()) 124 + .unwrap(), 125 + ) 126 + .await 127 + .unwrap(); 128 + 129 + assert_eq!( 130 + response.headers().get("content-type").unwrap(), 131 + "application/json" 132 + ); 133 + } 134 + 99 135 #[tokio::test] 100 136 async fn xrpc_response_body_is_method_not_implemented() { 101 137 let response = app(test_state()) ··· 108 144 .await 109 145 .unwrap(); 110 146 147 + let status = response.status(); 111 148 let body = axum::body::to_bytes(response.into_body(), 4096) 112 149 .await 113 150 .unwrap(); 114 151 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 152 + 153 + assert_eq!(status, StatusCode::NOT_IMPLEMENTED); 115 154 assert_eq!(json["error"]["code"], "MethodNotImplemented"); 116 155 } 117 156 }
+16 -8
crates/relay/src/main.rs
··· 53 53 tracing::info!(address = %addr, "listening"); 54 54 55 55 axum::serve(listener, app::app(state)) 56 - .with_graceful_shutdown(shutdown_signal()) 56 + .with_graceful_shutdown(async { 57 + if let Err(e) = shutdown_signal().await { 58 + tracing::error!(error = %e, "signal handler error"); 59 + } 60 + }) 57 61 .await 58 - .context("server error")?; 62 + .map_err(|e| { 63 + tracing::error!(error = %e, "axum server exited with error"); 64 + anyhow::anyhow!("server error: {e}") 65 + })?; 59 66 60 67 tracing::info!("relay shut down"); 61 68 Ok(()) 62 69 } 63 70 64 - async fn shutdown_signal() { 71 + async fn shutdown_signal() -> anyhow::Result<()> { 65 72 let ctrl_c = async { 66 73 tokio::signal::ctrl_c() 67 74 .await 68 - .expect("failed to install Ctrl+C handler"); 75 + .context("failed to install Ctrl+C handler") 69 76 }; 70 77 71 78 #[cfg(unix)] 72 79 let terminate = async { 73 80 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) 74 - .expect("failed to install SIGTERM handler") 81 + .context("failed to install SIGTERM handler")? 75 82 .recv() 76 83 .await; 84 + Ok(()) 77 85 }; 78 86 79 87 #[cfg(not(unix))] 80 - let terminate = std::future::pending::<()>(); 88 + let terminate = std::future::pending::<anyhow::Result<()>>(); 81 89 82 90 tokio::select! { 83 - _ = ctrl_c => {}, 84 - _ = terminate => {}, 91 + result = ctrl_c => result, 92 + result = terminate => result, 85 93 } 86 94 }