An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
at main 272 lines 9.6 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 // TODO: add remaining codes from Appendix A as endpoints are implemented: 54 // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION 55 // 401: INVALID_CREDENTIALS 56 // 403: TIER_RESTRICTED, DIDWEB_REQUIRES_DOMAIN, SINGLE_DEVICE_TIER 57 // 404: DEVICE_NOT_FOUND, DID_NOT_FOUND, NOT_IN_GRACE_PERIOD 58 // 409: ACCOUNT_NOT_FOUND, DEVICE_LIMIT, DID_EXISTS, 59 // ROTATION_IN_PROGRESS, LEASE_HELD, MIGRATION_IN_PROGRESS, ACTIVE_MIGRATION 60 // 410: ALREADY_DELETED 61 // 422: INVALID_KEY, KEY_MISMATCH, DIDWEB_SELF_SERVICE 62 // 423: ACCOUNT_LOCKED 63} 64 65impl ErrorCode { 66 /// Returns the canonical HTTP status code for this error as a `u16`. 67 pub fn status_code(&self) -> u16 { 68 match self { 69 ErrorCode::InvalidClaim => 400, 70 ErrorCode::Unauthorized => 401, 71 ErrorCode::TokenExpired => 401, 72 ErrorCode::Forbidden => 403, 73 ErrorCode::NotFound => 404, 74 ErrorCode::WeakPassword => 422, 75 ErrorCode::RateLimited => 429, 76 ErrorCode::ExportInProgress => 503, 77 ErrorCode::ServiceUnavailable => 503, 78 ErrorCode::InternalError => 500, 79 ErrorCode::MethodNotImplemented => 501, 80 ErrorCode::AccountExists => 409, 81 ErrorCode::HandleTaken => 409, 82 ErrorCode::InvalidHandle => 400, 83 ErrorCode::ClaimCodeRedeemed => 409, 84 ErrorCode::DidAlreadyExists => 409, 85 ErrorCode::PlcDirectoryError => 502, 86 ErrorCode::DnsError => 502, 87 ErrorCode::HandleNotFound => 404, 88 ErrorCode::AuthenticationRequired => 401, 89 ErrorCode::InvalidToken => 401, 90 } 91 } 92} 93 94/// Provisioning API error, serialized as the standard error envelope. 95/// 96/// Without details: 97/// ```json 98/// { "error": { "code": "NOT_FOUND", "message": "..." } } 99/// ``` 100/// 101/// With details: 102/// ```json 103/// { "error": { "code": "INVALID_CLAIM", "message": "...", "details": { "field": "email" } } } 104/// ``` 105/// 106/// Implements `IntoResponse` for Axum when the `axum` feature is enabled. 107#[derive(Debug, Serialize, thiserror::Error)] 108#[error("{code:?}: {message}")] 109pub struct ApiError { 110 code: ErrorCode, 111 message: String, 112 #[serde(skip_serializing_if = "Option::is_none")] 113 details: Option<Value>, 114} 115 116impl ApiError { 117 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self { 118 Self { 119 code, 120 message: message.into(), 121 details: None, 122 } 123 } 124 125 pub fn with_details(mut self, details: Value) -> Self { 126 self.details = Some(details); 127 self 128 } 129 130 /// Returns the HTTP status code for this error as a `u16`. 131 pub fn status_code(&self) -> u16 { 132 self.code.status_code() 133 } 134} 135 136/// Wraps `ApiError` in the `{ "error": ... }` envelope for serialization. 137#[cfg(any(feature = "axum", test))] 138#[derive(Serialize)] 139struct ApiErrorEnvelope { 140 error: ApiError, 141} 142 143#[cfg(feature = "axum")] 144mod axum_integration { 145 use super::*; 146 use axum::{ 147 http::{header, StatusCode}, 148 response::{IntoResponse, Response}, 149 Json, 150 }; 151 152 impl IntoResponse for ApiError { 153 fn into_response(self) -> Response { 154 let status = StatusCode::from_u16(self.code.status_code()) 155 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); 156 157 match serde_json::to_vec(&ApiErrorEnvelope { error: self }) { 158 Ok(body) => { 159 (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() 160 } 161 Err(err) => { 162 tracing::error!(error = %err, "failed to serialize ApiError"); 163 ( 164 StatusCode::INTERNAL_SERVER_ERROR, 165 Json(serde_json::json!({ 166 "error": { 167 "code": "INTERNAL_SERVER_ERROR", 168 "message": "internal error" 169 } 170 })), 171 ) 172 .into_response() 173 } 174 } 175 } 176 } 177} 178 179#[cfg(test)] 180mod tests { 181 use super::*; 182 use serde_json::json; 183 184 #[test] 185 fn serializes_to_error_envelope() { 186 let err = ApiError::new(ErrorCode::NotFound, "resource not found"); 187 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 188 assert_eq!( 189 actual, 190 json!({ 191 "error": { 192 "code": "NOT_FOUND", 193 "message": "resource not found" 194 } 195 }) 196 ); 197 } 198 199 #[test] 200 fn serializes_with_details() { 201 let err = ApiError::new(ErrorCode::InvalidClaim, "validation failed") 202 .with_details(json!({ "field": "email" })); 203 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 204 assert_eq!( 205 actual, 206 json!({ 207 "error": { 208 "code": "INVALID_CLAIM", 209 "message": "validation failed", 210 "details": { "field": "email" } 211 } 212 }) 213 ); 214 } 215 216 #[test] 217 fn omits_details_when_absent() { 218 let err = ApiError::new(ErrorCode::Forbidden, "access denied"); 219 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 220 assert!(!actual["error"].as_object().unwrap().contains_key("details")); 221 } 222 223 #[test] 224 fn status_code_mapping() { 225 let cases = [ 226 (ErrorCode::InvalidClaim, 400u16), 227 (ErrorCode::Unauthorized, 401), 228 (ErrorCode::TokenExpired, 401), 229 (ErrorCode::Forbidden, 403), 230 (ErrorCode::NotFound, 404), 231 (ErrorCode::WeakPassword, 422), 232 (ErrorCode::RateLimited, 429), 233 (ErrorCode::ExportInProgress, 503), 234 (ErrorCode::ServiceUnavailable, 503), 235 (ErrorCode::InternalError, 500), 236 (ErrorCode::MethodNotImplemented, 501), 237 (ErrorCode::AccountExists, 409), 238 (ErrorCode::HandleTaken, 409), 239 (ErrorCode::InvalidHandle, 400), 240 (ErrorCode::ClaimCodeRedeemed, 409), 241 (ErrorCode::DidAlreadyExists, 409), 242 (ErrorCode::PlcDirectoryError, 502), 243 (ErrorCode::DnsError, 502), 244 (ErrorCode::HandleNotFound, 404), 245 (ErrorCode::AuthenticationRequired, 401), 246 (ErrorCode::InvalidToken, 401), 247 ]; 248 for (code, expected) in cases { 249 assert_eq!(code.status_code(), expected, "wrong status for {code:?}"); 250 } 251 } 252 253 #[cfg(feature = "axum")] 254 mod axum_tests { 255 use super::*; 256 use axum::http::StatusCode; 257 use axum::response::IntoResponse; 258 259 #[tokio::test] 260 async fn into_response_correct_status_and_body() { 261 let err = ApiError::new(ErrorCode::NotFound, "not found"); 262 let response = err.into_response(); 263 assert_eq!(response.status(), StatusCode::NOT_FOUND); 264 let body = axum::body::to_bytes(response.into_body(), usize::MAX) 265 .await 266 .unwrap(); 267 let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 268 assert_eq!(json["error"]["code"], "NOT_FOUND"); 269 assert_eq!(json["error"]["message"], "not found"); 270 } 271 } 272}