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.

at main 287 lines 10 kB view raw
1use serde::Serialize; 2use serde_json::Value; 3 4/// Error codes for the provisioning API. 5/// 6/// Most variants serialize as SCREAMING_SNAKE_CASE. Exceptions use `#[serde(rename)]` 7/// when a specific wire format is required (e.g. `MethodNotImplemented` uses PascalCase 8/// to match the AT Protocol XRPC error format). 9/// 10/// `#[non_exhaustive]` prevents external crates from writing exhaustive match 11/// arms — new variants can be added in future waves without breaking callers. 12#[non_exhaustive] 13#[derive(Debug, Clone, PartialEq, Eq, Serialize)] 14#[serde(rename_all = "SCREAMING_SNAKE_CASE")] 15pub enum ErrorCode { 16 InvalidClaim, 17 Unauthorized, 18 TokenExpired, 19 Forbidden, 20 NotFound, 21 WeakPassword, 22 RateLimited, 23 ExportInProgress, 24 ServiceUnavailable, 25 InternalError, 26 /// Returned for any XRPC NSID that has no registered handler. 27 /// 28 /// Serialized as `"MethodNotImplemented"` (PascalCase) to match the AT Protocol XRPC 29 /// error format, which uses PascalCase error names rather than SCREAMING_SNAKE_CASE. 30 #[serde(rename = "MethodNotImplemented")] 31 MethodNotImplemented, 32 /// An account with the given email already exists (pending or active). 33 AccountExists, 34 /// The requested handle is already claimed by an active or pending account. 35 HandleTaken, 36 /// The handle string failed basic format validation. 37 InvalidHandle, 38 /// A claim code that has already been redeemed is presented again. 39 /// Clients should inform the user to obtain a different code. 40 ClaimCodeRedeemed, 41 /// The DID has already been fully promoted to an active account. 42 DidAlreadyExists, 43 /// The external PLC directory returned a non-success response. 44 PlcDirectoryError, 45 /// A configured DNS provider returned an error when creating a subdomain record. 46 DnsError, 47 /// The requested handle does not resolve to a known DID locally or via DNS. 48 HandleNotFound, 49 /// Missing or absent Authorization header on a protected endpoint. 50 AuthenticationRequired, 51 /// Token is structurally invalid, has wrong signature, wrong audience, or DPoP mismatch. 52 InvalidToken, 53 /// A password-reset token has expired or has already been used. 54 /// 55 /// Serialized as `"ExpiredToken"` (PascalCase) to match the AT Protocol XRPC error format 56 /// for `com.atproto.server.resetPassword`. 57 #[serde(rename = "ExpiredToken")] 58 ExpiredToken, 59 // TODO: add remaining codes from Appendix A as endpoints are implemented: 60 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION 61 // 401: INVALID_CREDENTIALS 62 // 403: TIER_RESTRICTED, DIDWEB_REQUIRES_DOMAIN, SINGLE_DEVICE_TIER 63 // 404: DEVICE_NOT_FOUND, DID_NOT_FOUND, NOT_IN_GRACE_PERIOD 64 // 409: ACCOUNT_NOT_FOUND, DEVICE_LIMIT, DID_EXISTS, 65 // ROTATION_IN_PROGRESS, LEASE_HELD, MIGRATION_IN_PROGRESS, ACTIVE_MIGRATION 66 // 410: ALREADY_DELETED 67 // 422: INVALID_KEY, KEY_MISMATCH, DIDWEB_SELF_SERVICE 68 // 423: ACCOUNT_LOCKED 69} 70 71impl ErrorCode { 72 /// Returns the canonical HTTP status code for this error as a `u16`. 73 pub fn status_code(&self) -> u16 { 74 match self { 75 ErrorCode::InvalidClaim => 400, 76 ErrorCode::Unauthorized => 401, 77 ErrorCode::TokenExpired => 401, 78 ErrorCode::Forbidden => 403, 79 ErrorCode::NotFound => 404, 80 ErrorCode::WeakPassword => 422, 81 ErrorCode::RateLimited => 429, 82 ErrorCode::ExportInProgress => 503, 83 ErrorCode::ServiceUnavailable => 503, 84 ErrorCode::InternalError => 500, 85 ErrorCode::MethodNotImplemented => 501, 86 ErrorCode::AccountExists => 409, 87 ErrorCode::HandleTaken => 409, 88 ErrorCode::InvalidHandle => 400, 89 ErrorCode::ClaimCodeRedeemed => 409, 90 ErrorCode::DidAlreadyExists => 409, 91 ErrorCode::PlcDirectoryError => 502, 92 ErrorCode::DnsError => 502, 93 ErrorCode::HandleNotFound => 404, 94 ErrorCode::AuthenticationRequired => 401, 95 ErrorCode::InvalidToken => 401, 96 ErrorCode::ExpiredToken => 400, 97 } 98 } 99} 100 101/// Provisioning API error, serialized as the standard error envelope. 102/// 103/// Without details: 104/// ```json 105/// { "error": { "code": "NOT_FOUND", "message": "..." } } 106/// ``` 107/// 108/// With details: 109/// ```json 110/// { "error": { "code": "INVALID_CLAIM", "message": "...", "details": { "field": "email" } } } 111/// ``` 112/// 113/// Implements `IntoResponse` for Axum when the `axum` feature is enabled. 114#[derive(Debug, Serialize, thiserror::Error)] 115#[error("{code:?}: {message}")] 116pub struct ApiError { 117 code: ErrorCode, 118 message: String, 119 #[serde(skip_serializing_if = "Option::is_none")] 120 details: Option<Value>, 121} 122 123impl ApiError { 124 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self { 125 Self { 126 code, 127 message: message.into(), 128 details: None, 129 } 130 } 131 132 pub fn with_details(mut self, details: Value) -> Self { 133 self.details = Some(details); 134 self 135 } 136 137 /// Returns the HTTP status code for this error as a `u16`. 138 pub fn status_code(&self) -> u16 { 139 self.code.status_code() 140 } 141} 142 143/// Wraps `ApiError` in the `{ "error": ... }` envelope for serialization. 144#[cfg(any(feature = "axum", test))] 145#[derive(Serialize)] 146struct ApiErrorEnvelope { 147 error: ApiError, 148} 149 150#[cfg(feature = "axum")] 151mod axum_integration { 152 use super::*; 153 use axum::{ 154 http::{header, StatusCode}, 155 response::{IntoResponse, Response}, 156 Json, 157 }; 158 159 impl IntoResponse for ApiError { 160 fn into_response(self) -> Response { 161 let status = StatusCode::from_u16(self.code.status_code()) 162 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); 163 164 match serde_json::to_vec(&ApiErrorEnvelope { error: self }) { 165 Ok(body) => { 166 (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() 167 } 168 Err(err) => { 169 tracing::error!(error = %err, "failed to serialize ApiError"); 170 ( 171 StatusCode::INTERNAL_SERVER_ERROR, 172 Json(serde_json::json!({ 173 "error": { 174 "code": "INTERNAL_SERVER_ERROR", 175 "message": "internal error" 176 } 177 })), 178 ) 179 .into_response() 180 } 181 } 182 } 183 } 184} 185 186#[cfg(test)] 187mod tests { 188 use super::*; 189 use serde_json::json; 190 191 #[test] 192 fn serializes_to_error_envelope() { 193 let err = ApiError::new(ErrorCode::NotFound, "resource not found"); 194 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 195 assert_eq!( 196 actual, 197 json!({ 198 "error": { 199 "code": "NOT_FOUND", 200 "message": "resource not found" 201 } 202 }) 203 ); 204 } 205 206 #[test] 207 fn serializes_with_details() { 208 let err = ApiError::new(ErrorCode::InvalidClaim, "validation failed") 209 .with_details(json!({ "field": "email" })); 210 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 211 assert_eq!( 212 actual, 213 json!({ 214 "error": { 215 "code": "INVALID_CLAIM", 216 "message": "validation failed", 217 "details": { "field": "email" } 218 } 219 }) 220 ); 221 } 222 223 #[test] 224 fn expired_token_serializes_as_pascal_case() { 225 let err = ApiError::new(ErrorCode::ExpiredToken, "token has expired"); 226 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 227 assert_eq!(actual["error"]["code"], "ExpiredToken"); 228 } 229 230 #[test] 231 fn omits_details_when_absent() { 232 let err = ApiError::new(ErrorCode::Forbidden, "access denied"); 233 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 234 assert!(!actual["error"].as_object().unwrap().contains_key("details")); 235 } 236 237 #[test] 238 fn status_code_mapping() { 239 let cases = [ 240 (ErrorCode::InvalidClaim, 400u16), 241 (ErrorCode::Unauthorized, 401), 242 (ErrorCode::TokenExpired, 401), 243 (ErrorCode::Forbidden, 403), 244 (ErrorCode::NotFound, 404), 245 (ErrorCode::WeakPassword, 422), 246 (ErrorCode::RateLimited, 429), 247 (ErrorCode::ExportInProgress, 503), 248 (ErrorCode::ServiceUnavailable, 503), 249 (ErrorCode::InternalError, 500), 250 (ErrorCode::MethodNotImplemented, 501), 251 (ErrorCode::AccountExists, 409), 252 (ErrorCode::HandleTaken, 409), 253 (ErrorCode::InvalidHandle, 400), 254 (ErrorCode::ClaimCodeRedeemed, 409), 255 (ErrorCode::DidAlreadyExists, 409), 256 (ErrorCode::PlcDirectoryError, 502), 257 (ErrorCode::DnsError, 502), 258 (ErrorCode::HandleNotFound, 404), 259 (ErrorCode::AuthenticationRequired, 401), 260 (ErrorCode::InvalidToken, 401), 261 (ErrorCode::ExpiredToken, 400), 262 ]; 263 for (code, expected) in cases { 264 assert_eq!(code.status_code(), expected, "wrong status for {code:?}"); 265 } 266 } 267 268 #[cfg(feature = "axum")] 269 mod axum_tests { 270 use super::*; 271 use axum::http::StatusCode; 272 use axum::response::IntoResponse; 273 274 #[tokio::test] 275 async fn into_response_correct_status_and_body() { 276 let err = ApiError::new(ErrorCode::NotFound, "not found"); 277 let response = err.into_response(); 278 assert_eq!(response.status(), StatusCode::NOT_FOUND); 279 let body = axum::body::to_bytes(response.into_body(), usize::MAX) 280 .await 281 .unwrap(); 282 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 283 assert_eq!(json["error"]["code"], "NOT_FOUND"); 284 assert_eq!(json["error"]["message"], "not found"); 285 } 286 } 287}