QuickDID is a high-performance AT Protocol identity resolution service written in Rust. It provides handle-to-DID resolution with Redis-backed caching and queue processing.

refactor: resolve handle view

+3
Cargo.lock
··· 314 314 dependencies = [ 315 315 "android-tzdata", 316 316 "iana-time-zone", 317 + "js-sys", 317 318 "num-traits", 319 + "wasm-bindgen", 318 320 "windows-link", 319 321 ] 320 322 ··· 1902 1904 "atproto-identity", 1903 1905 "axum", 1904 1906 "bincode", 1907 + "chrono", 1905 1908 "deadpool-redis", 1906 1909 "metrohash", 1907 1910 "reqwest",
+1
Cargo.toml
··· 19 19 atproto-identity = { version = "0.11.3" } 20 20 axum = { version = "0.8" } 21 21 bincode = { version = "2.0.1", features = ["serde"] } 22 + chrono = "0.4" 22 23 deadpool-redis = { version = "0.22", features = ["connection-manager", "tokio-comp", "tokio-rustls-comp"] } 23 24 metrohash = "1.0.7" 24 25 reqwest = { version = "0.12", features = ["json"] }
+196 -92
src/http/handle_xrpc_resolve_handle.rs
··· 1 + use chrono::{DateTime, Utc}; 1 2 use std::sync::Arc; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 2 4 3 5 use crate::{ 4 6 handle_resolver::HandleResolver, ··· 34 36 message: String, 35 37 } 36 38 39 + /// Represents the result of a handle resolution 40 + enum ResolutionResult { 41 + Success { 42 + did: String, 43 + timestamp: u64, 44 + etag: String, 45 + }, 46 + Error { 47 + error: String, 48 + message: String, 49 + timestamp: u64, 50 + etag: String, 51 + }, 52 + } 53 + 54 + struct ResolutionResultView { 55 + result: ResolutionResult, 56 + cache_control: Option<String>, 57 + if_none_match: Option<HeaderValue>, 58 + if_modified_since: Option<HeaderValue>, 59 + } 60 + 61 + impl IntoResponse for ResolutionResultView { 62 + fn into_response(self) -> Response { 63 + let (last_modified, etag) = match &self.result { 64 + ResolutionResult::Success { 65 + timestamp, etag, .. 66 + } => (*timestamp, etag), 67 + ResolutionResult::Error { 68 + timestamp, etag, .. 69 + } => (*timestamp, etag), 70 + }; 71 + 72 + let mut headers = HeaderMap::new(); 73 + 74 + // WARNING: this swallows errors 75 + if let Ok(etag_value) = HeaderValue::from_str(etag) { 76 + headers.insert(header::ETAG, etag_value); 77 + } 78 + 79 + // Add Last-Modified header 80 + let last_modified_date = format_http_date(last_modified); 81 + // WARNING: this swallows errors 82 + if let Ok(last_modified_value) = HeaderValue::from_str(&last_modified_date) { 83 + headers.insert(header::LAST_MODIFIED, last_modified_value); 84 + } 85 + 86 + // Add Cache-Control header if configured 87 + if let Some(cache_control) = &self.cache_control { 88 + // WARNING: this swallows errors 89 + if let Ok(cache_control_value) = HeaderValue::from_str(cache_control) { 90 + headers.insert(header::CACHE_CONTROL, cache_control_value); 91 + } 92 + } 93 + 94 + headers.insert("Allow", HeaderValue::from_static("GET, HEAD, OPTIONS")); 95 + headers.insert( 96 + header::ACCESS_CONTROL_ALLOW_HEADERS, 97 + HeaderValue::from_static("*"), 98 + ); 99 + headers.insert( 100 + header::ACCESS_CONTROL_ALLOW_METHODS, 101 + HeaderValue::from_static("GET, HEAD, OPTIONS"), 102 + ); 103 + headers.insert( 104 + header::ACCESS_CONTROL_ALLOW_ORIGIN, 105 + HeaderValue::from_static("*"), 106 + ); 107 + headers.insert( 108 + header::ACCESS_CONTROL_EXPOSE_HEADERS, 109 + HeaderValue::from_static("*"), 110 + ); 111 + headers.insert( 112 + header::ACCESS_CONTROL_MAX_AGE, 113 + HeaderValue::from_static("86400"), 114 + ); 115 + headers.insert( 116 + "Access-Control-Request-Headers", 117 + HeaderValue::from_static("*"), 118 + ); 119 + headers.insert( 120 + "Access-Control-Request-Method", 121 + HeaderValue::from_static("GET"), 122 + ); 123 + 124 + if let ResolutionResult::Success { .. } = self.result { 125 + let fresh = self 126 + .if_modified_since 127 + .and_then(|inner_header_value| match inner_header_value.to_str() { 128 + Ok(value) => Some(value.to_string()), 129 + Err(_) => None, 130 + }) 131 + .and_then(|inner_str_value| parse_http_date(&inner_str_value)) 132 + .is_some_and(|inner_if_modified_since| last_modified <= inner_if_modified_since); 133 + 134 + if fresh { 135 + return (StatusCode::NOT_MODIFIED, headers).into_response(); 136 + } 137 + } 138 + 139 + let fresh = self 140 + .if_none_match 141 + .is_some_and(|if_none_match_value| if_none_match_value == etag); 142 + if fresh { 143 + return (StatusCode::NOT_MODIFIED, headers).into_response(); 144 + } 145 + 146 + match &self.result { 147 + ResolutionResult::Success { did, .. } => ( 148 + StatusCode::OK, 149 + headers, 150 + Json(ResolveHandleResponse { did: did.clone() }), 151 + ) 152 + .into_response(), 153 + ResolutionResult::Error { error, message, .. } => ( 154 + StatusCode::BAD_REQUEST, 155 + headers, 156 + Json(ErrorResponse { 157 + error: error.clone(), 158 + message: message.clone(), 159 + }), 160 + ) 161 + .into_response(), 162 + } 163 + 164 + // (status_code, headers).into_response() 165 + } 166 + } 167 + 37 168 /// Calculate a weak ETag for the given content using MetroHash64 with a seed 38 169 fn calculate_etag(content: &str, seed: &str) -> String { 39 170 let mut hasher = MetroHash64::new(); ··· 43 174 format!("W/\"{:x}\"", hash) 44 175 } 45 176 177 + /// Format a UNIX timestamp as an HTTP date string (RFC 7231) 178 + fn format_http_date(timestamp: u64) -> String { 179 + let datetime = DateTime::from_timestamp(timestamp as i64, 0).unwrap_or_else(Utc::now); 180 + 181 + datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string() 182 + } 183 + 184 + /// Parse an HTTP date string (RFC 7231) into a UNIX timestamp 185 + fn parse_http_date(date_str: &str) -> Option<u64> { 186 + use chrono::{DateTime, Utc}; 187 + 188 + // Try parsing with the standard HTTP date format 189 + DateTime::parse_from_rfc2822(date_str) 190 + .ok() 191 + .map(|dt| dt.with_timezone(&Utc).timestamp() as u64) 192 + } 193 + 46 194 pub(super) async fn handle_xrpc_resolve_handle( 47 195 headers: HeaderMap, 48 196 Query(params): Query<ResolveHandleParams>, 49 197 State(app_context): State<AppContext>, 50 198 State(handle_resolver): State<Arc<dyn HandleResolver>>, 51 199 State(queue): State<Arc<dyn QueueAdapter<HandleResolutionWork>>>, 52 - ) -> Result<Response, Response> { 200 + ) -> impl IntoResponse { 53 201 let validating = params.validate.is_some(); 54 202 let queueing = params.queue.is_some(); 55 203 ··· 57 205 let handle = match params.handle { 58 206 Some(h) => h, 59 207 None => { 60 - return Err(( 208 + return ( 61 209 StatusCode::BAD_REQUEST, 62 210 Json(ErrorResponse { 63 211 error: "InvalidRequest".to_string(), 64 212 message: "Error: Params must have the property \"handle\"".to_string(), 65 213 }), 66 214 ) 67 - .into_response()); 215 + .into_response(); 68 216 } 69 217 }; 70 218 ··· 73 221 Ok(InputType::Handle(value)) => value, 74 222 Ok(InputType::Plc(_)) | Ok(InputType::Web(_)) => { 75 223 // It's a DID, not a handle 76 - return Err(( 224 + return ( 77 225 StatusCode::BAD_REQUEST, 78 226 Json(ErrorResponse { 79 227 error: "InvalidRequest".to_string(), 80 228 message: "Error: handle must be a valid handle".to_string(), 81 229 }), 82 230 ) 83 - .into_response()); 231 + .into_response(); 84 232 } 85 233 Err(_) => { 86 - return Err(( 234 + return ( 87 235 StatusCode::BAD_REQUEST, 88 236 Json(ErrorResponse { 89 237 error: "InvalidRequest".to_string(), 90 238 message: "Error: handle must be a valid handle".to_string(), 91 239 }), 92 240 ) 93 - .into_response()); 241 + .into_response(); 94 242 } 95 243 }; 96 244 97 245 if validating { 98 - return Ok(StatusCode::NO_CONTENT.into_response()); 246 + return StatusCode::NO_CONTENT.into_response(); 99 247 } 100 248 101 249 if queueing { ··· 112 260 } 113 261 } 114 262 115 - return Ok(StatusCode::NO_CONTENT.into_response()); 263 + return StatusCode::NO_CONTENT.into_response(); 116 264 } 117 265 118 266 tracing::debug!(handle, "Resolving handle"); 119 267 120 - let if_none_match = headers.get(header::IF_NONE_MATCH); 268 + // Get conditional request headers 269 + let if_none_match = headers.get(header::IF_NONE_MATCH).cloned(); 270 + let if_modified_since = headers.get(header::IF_MODIFIED_SINCE).cloned(); 121 271 122 - let (mut response, etag) = match handle_resolver.resolve(&handle).await { 123 - Ok((did, _timestamp)) => { 272 + // Perform the resolution and build the response 273 + let result = match handle_resolver.resolve(&handle).await { 274 + Ok((did, timestamp)) => { 124 275 tracing::debug!(handle, did, "Found cached DID for handle"); 125 - 126 - // Calculate the weak etag for the successful response 127 276 let etag = calculate_etag(&did, app_context.etag_seed()); 128 - 129 - // Check if the client's etag matches our calculated one 130 - if if_none_match.is_some_and(|value| value == &etag) { 131 - (StatusCode::NOT_MODIFIED.into_response(), etag) 132 - } else { 133 - (Json(ResolveHandleResponse { did }).into_response(), etag) 277 + ResolutionResult::Success { 278 + did, 279 + timestamp, 280 + etag, 134 281 } 135 282 } 136 283 Err(err) => { 137 284 tracing::debug!(error = ?err, handle, "Error resolving handle"); 138 - 139 - // Calculate the weak etag for the error response 140 - // Use a combination of error message and handle for consistent error etags 141 285 let error_content = format!("error:{}:{}", handle, err); 142 286 let etag = calculate_etag(&error_content, app_context.etag_seed()); 143 - 144 - if if_none_match.is_some_and(|value| value == &etag) { 145 - (StatusCode::NOT_MODIFIED.into_response(), etag) 146 - } else { 147 - ( 148 - ( 149 - StatusCode::BAD_REQUEST, 150 - Json(ErrorResponse { 151 - error: "InvalidRequest".to_string(), 152 - message: "Unable to resolve handle".to_string(), 153 - }), 154 - ) 155 - .into_response(), 156 - etag, 157 - ) 287 + let timestamp = SystemTime::now() 288 + .duration_since(UNIX_EPOCH) 289 + .unwrap_or_default() 290 + .as_secs(); 291 + ResolutionResult::Error { 292 + error: "InvalidRequest".to_string(), 293 + message: "Unable to resolve handle".to_string(), 294 + timestamp, 295 + etag, 158 296 } 159 297 } 160 298 }; 161 299 162 - let headers = response.headers_mut(); 163 - 164 - // Add ETag header 165 - match HeaderValue::from_str(&etag) { 166 - Ok(etag_header_value) => { 167 - headers.insert(header::ETAG, etag_header_value); 168 - } 169 - Err(err) => { 170 - tracing::error!(error = ?err, "unable to create etag response value"); 171 - } 172 - } 173 - 174 - // Add Cache-Control header if configured 175 - if let Some(cache_control) = app_context.cache_control_header() 176 - && let Ok(cache_control_value) = HeaderValue::from_str(cache_control) 177 - { 178 - headers.insert(header::CACHE_CONTROL, cache_control_value); 300 + ResolutionResultView { 301 + result, 302 + cache_control: app_context.cache_control_header().map(|s| s.to_string()), 303 + if_none_match, 304 + if_modified_since, 179 305 } 306 + .into_response() 180 307 181 - // Add CORS and Allow headers 182 - headers.insert("Allow", HeaderValue::from_static("GET, HEAD, OPTIONS")); 183 - headers.insert( 184 - header::ACCESS_CONTROL_ALLOW_HEADERS, 185 - HeaderValue::from_static("*"), 186 - ); 187 - headers.insert( 188 - header::ACCESS_CONTROL_ALLOW_METHODS, 189 - HeaderValue::from_static("GET, HEAD, OPTIONS"), 190 - ); 191 - headers.insert( 192 - header::ACCESS_CONTROL_ALLOW_ORIGIN, 193 - HeaderValue::from_static("*"), 194 - ); 195 - headers.insert( 196 - header::ACCESS_CONTROL_EXPOSE_HEADERS, 197 - HeaderValue::from_static("*"), 198 - ); 199 - headers.insert( 200 - header::ACCESS_CONTROL_MAX_AGE, 201 - HeaderValue::from_static("86400"), 202 - ); 203 - headers.insert( 204 - "Access-Control-Request-Headers", 205 - HeaderValue::from_static("*"), 206 - ); 207 - headers.insert( 208 - "Access-Control-Request-Method", 209 - HeaderValue::from_static("GET"), 210 - ); 308 + // Build the response using the builder 309 + // let response = HandleResponseBuilder::new( 310 + // result, 311 + // app_context.cache_control_header().map(|s| s.to_string()), 312 + // if_none_match, 313 + // if_modified_since, 314 + // ) 315 + // .build(); 211 316 212 - Ok(response) 317 + // Ok(response) 213 318 } 214 319 215 - pub(super) async fn handle_xrpc_resolve_handle_options() -> Response { 216 - let mut response = StatusCode::NO_CONTENT.into_response(); 217 - let headers = response.headers_mut(); 218 - 320 + pub(super) async fn handle_xrpc_resolve_handle_options() -> impl IntoResponse { 321 + let mut headers = HeaderMap::new(); 322 + 219 323 // Add CORS and Allow headers for OPTIONS request 220 324 headers.insert("Allow", HeaderValue::from_static("GET, HEAD, OPTIONS")); 221 325 headers.insert( ··· 246 350 "Access-Control-Request-Method", 247 351 HeaderValue::from_static("GET"), 248 352 ); 249 - 250 - response 353 + 354 + (StatusCode::NO_CONTENT, headers) 251 355 }