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(MM-70): address PR review — feature-gate axum, private fields, Error impl

- common/axum feature: IntoResponse opt-in via [features] axum = ["dep:axum"];
relay enables it, pure-logic crates stay lightweight (orphan rule prevents
moving impl to relay, so feature gating is the correct Rust idiom)
- ErrorCode::status_code() now returns u16 instead of axum::http::StatusCode
- ApiError fields made private; status_code(&self) -> u16 added as accessor
- ApiError derives thiserror::Error with #[error("{code:?}: {message}")]
- Removed Deserialize from ErrorCode (outward-only flow)
- IntoResponse fallback logs via tracing::error! on serialization failure
- End-to-end tokio::test verifies status code and body via .into_response()
- Fixed doc comment: shows both with-details and without-details examples
- Removed // pattern: Functional Core and temporal Wave 0–2 set comments
- Added TODO listing remaining Appendix A codes for later waves

authored by

Malpercio and committed by
Tangled
3ab398dd 124f71e9

+178 -42
+63
Cargo.lock
··· 212 212 "serde_json", 213 213 "tempfile", 214 214 "thiserror", 215 + "tokio", 215 216 "toml", 217 + "tracing", 216 218 ] 217 219 218 220 [[package]] ··· 459 461 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 460 462 461 463 [[package]] 464 + name = "lock_api" 465 + version = "0.4.14" 466 + source = "registry+https://github.com/rust-lang/crates.io-index" 467 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 468 + dependencies = [ 469 + "scopeguard", 470 + ] 471 + 472 + [[package]] 462 473 name = "log" 463 474 version = "0.4.29" 464 475 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 524 535 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 525 536 526 537 [[package]] 538 + name = "parking_lot" 539 + version = "0.12.5" 540 + source = "registry+https://github.com/rust-lang/crates.io-index" 541 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 542 + dependencies = [ 543 + "lock_api", 544 + "parking_lot_core", 545 + ] 546 + 547 + [[package]] 548 + name = "parking_lot_core" 549 + version = "0.9.12" 550 + source = "registry+https://github.com/rust-lang/crates.io-index" 551 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 552 + dependencies = [ 553 + "cfg-if", 554 + "libc", 555 + "redox_syscall", 556 + "smallvec", 557 + "windows-link", 558 + ] 559 + 560 + [[package]] 527 561 name = "percent-encoding" 528 562 version = "2.3.2" 529 563 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 576 610 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 577 611 578 612 [[package]] 613 + name = "redox_syscall" 614 + version = "0.5.18" 615 + source = "registry+https://github.com/rust-lang/crates.io-index" 616 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 617 + dependencies = [ 618 + "bitflags", 619 + ] 620 + 621 + [[package]] 579 622 name = "regex-automata" 580 623 version = "0.4.14" 581 624 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 597 640 version = "0.1.0" 598 641 dependencies = [ 599 642 "anyhow", 643 + "axum", 600 644 "clap", 601 645 "common", 602 646 "tracing", ··· 633 677 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 634 678 635 679 [[package]] 680 + name = "scopeguard" 681 + version = "1.2.0" 682 + source = "registry+https://github.com/rust-lang/crates.io-index" 683 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 684 + 685 + [[package]] 636 686 name = "semver" 637 687 version = "1.0.27" 638 688 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 723 773 ] 724 774 725 775 [[package]] 776 + name = "signal-hook-registry" 777 + version = "1.4.8" 778 + source = "registry+https://github.com/rust-lang/crates.io-index" 779 + checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" 780 + dependencies = [ 781 + "errno", 782 + "libc", 783 + ] 784 + 785 + [[package]] 726 786 name = "slab" 727 787 version = "0.4.12" 728 788 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 815 875 source = "registry+https://github.com/rust-lang/crates.io-index" 816 876 checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" 817 877 dependencies = [ 878 + "bytes", 818 879 "libc", 819 880 "mio", 881 + "parking_lot", 820 882 "pin-project-lite", 883 + "signal-hook-registry", 821 884 "socket2", 822 885 "tokio-macros", 823 886 "windows-sys",
+8 -1
crates/common/Cargo.toml
··· 6 6 7 7 # common: shared types, error envelope, config parsing. 8 8 9 + [features] 10 + # Enable Axum integration: ApiError implements IntoResponse. 11 + # relay enables this; pure-logic crates (crypto, repo-engine) do not. 12 + axum = ["dep:axum", "dep:tracing"] 13 + 9 14 [dependencies] 10 - axum = { workspace = true } 15 + axum = { workspace = true, optional = true } 16 + tracing = { workspace = true, optional = true } 11 17 serde = { workspace = true } 12 18 serde_json = { workspace = true } 13 19 toml = { workspace = true } ··· 15 21 16 22 [dev-dependencies] 17 23 tempfile = { workspace = true } 24 + tokio = { workspace = true }
+105 -40
crates/common/src/error.rs
··· 1 - // pattern: Functional Core 1 + // Shared error types and provisioning API error envelope. 2 2 3 - use axum::{ 4 - http::StatusCode, 5 - response::{IntoResponse, Response}, 6 - Json, 7 - }; 8 - use serde::{Deserialize, Serialize}; 3 + use serde::Serialize; 9 4 use serde_json::Value; 10 5 11 6 /// Error codes for the provisioning API. 12 7 /// 13 - /// Serialized as SCREAMING_SNAKE_CASE strings in the JSON envelope. 8 + /// Serialized as SCREAMING_SNAKE_CASE strings in the JSON error envelope. 14 9 /// `#[non_exhaustive]` prevents external crates from writing exhaustive match 15 10 /// arms — new variants can be added in future waves without breaking callers. 16 11 #[non_exhaustive] 17 - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 12 + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] 18 13 #[serde(rename_all = "SCREAMING_SNAKE_CASE")] 19 14 pub enum ErrorCode { 20 - // Wave 0–2 initial set 21 15 InvalidClaim, 22 16 Unauthorized, 23 17 TokenExpired, ··· 26 20 WeakPassword, 27 21 RateLimited, 28 22 ExportInProgress, 23 + // TODO: add remaining codes from Appendix A as endpoints are implemented: 24 + // 400: INVALID_DOCUMENT, INVALID_PROOF, INVALID_ENDPOINT, INVALID_CONFIRMATION 25 + // 401: INVALID_CREDENTIALS 26 + // 403: TIER_RESTRICTED, DIDWEB_REQUIRES_DOMAIN, SINGLE_DEVICE_TIER 27 + // 404: DEVICE_NOT_FOUND, DID_NOT_FOUND, HANDLE_NOT_FOUND, NOT_IN_GRACE_PERIOD 28 + // 409: ACCOUNT_EXISTS, DEVICE_LIMIT, DID_EXISTS, HANDLE_TAKEN, 29 + // ROTATION_IN_PROGRESS, LEASE_HELD, MIGRATION_IN_PROGRESS, ACTIVE_MIGRATION 30 + // 410: ALREADY_DELETED 31 + // 422: INVALID_KEY, INVALID_HANDLE, KEY_MISMATCH, DIDWEB_SELF_SERVICE 32 + // 423: ACCOUNT_LOCKED 29 33 } 30 34 31 35 impl ErrorCode { 32 - /// Maps each error code to its canonical HTTP status code. 33 - pub fn status_code(&self) -> StatusCode { 36 + /// Returns the canonical HTTP status code for this error as a `u16`. 37 + pub fn status_code(&self) -> u16 { 34 38 match self { 35 - ErrorCode::InvalidClaim => StatusCode::BAD_REQUEST, 36 - ErrorCode::Unauthorized => StatusCode::UNAUTHORIZED, 37 - ErrorCode::TokenExpired => StatusCode::UNAUTHORIZED, 38 - ErrorCode::Forbidden => StatusCode::FORBIDDEN, 39 - ErrorCode::NotFound => StatusCode::NOT_FOUND, 40 - ErrorCode::WeakPassword => StatusCode::UNPROCESSABLE_ENTITY, 41 - ErrorCode::RateLimited => StatusCode::TOO_MANY_REQUESTS, 42 - ErrorCode::ExportInProgress => StatusCode::SERVICE_UNAVAILABLE, 39 + ErrorCode::InvalidClaim => 400, 40 + ErrorCode::Unauthorized => 401, 41 + ErrorCode::TokenExpired => 401, 42 + ErrorCode::Forbidden => 403, 43 + ErrorCode::NotFound => 404, 44 + ErrorCode::WeakPassword => 422, 45 + ErrorCode::RateLimited => 429, 46 + ErrorCode::ExportInProgress => 503, 43 47 } 44 48 } 45 49 } 46 50 47 - /// Provisioning API error, serialized as the standard error envelope: 51 + /// Provisioning API error, serialized as the standard error envelope. 48 52 /// 53 + /// Without details: 49 54 /// ```json 50 - /// { "error": { "code": "NOT_FOUND", "message": "...", "details": {} } } 55 + /// { "error": { "code": "NOT_FOUND", "message": "..." } } 51 56 /// ``` 52 57 /// 53 - /// Implements `IntoResponse` so it can be returned directly from Axum handlers. 54 - #[derive(Debug, Serialize)] 58 + /// With details: 59 + /// ```json 60 + /// { "error": { "code": "INVALID_CLAIM", "message": "...", "details": { "field": "email" } } } 61 + /// ``` 62 + /// 63 + /// Implements `IntoResponse` for Axum when the `axum` feature is enabled. 64 + #[derive(Debug, Serialize, thiserror::Error)] 65 + #[error("{code:?}: {message}")] 55 66 pub struct ApiError { 56 - pub code: ErrorCode, 57 - pub message: String, 67 + code: ErrorCode, 68 + message: String, 58 69 #[serde(skip_serializing_if = "Option::is_none")] 59 - pub details: Option<Value>, 70 + details: Option<Value>, 60 71 } 61 72 62 73 impl ApiError { ··· 72 83 self.details = Some(details); 73 84 self 74 85 } 86 + 87 + /// Returns the HTTP status code for this error as a `u16`. 88 + pub fn status_code(&self) -> u16 { 89 + self.code.status_code() 90 + } 75 91 } 76 92 77 93 /// Wraps `ApiError` in the `{ "error": ... }` envelope for serialization. 94 + #[cfg(any(feature = "axum", test))] 78 95 #[derive(Serialize)] 79 96 struct ApiErrorEnvelope { 80 97 error: ApiError, 81 98 } 82 99 83 - impl IntoResponse for ApiError { 84 - fn into_response(self) -> Response { 85 - let status = self.code.status_code(); 86 - (status, Json(ApiErrorEnvelope { error: self })).into_response() 100 + #[cfg(feature = "axum")] 101 + mod axum_integration { 102 + use super::*; 103 + use axum::{ 104 + http::{header, StatusCode}, 105 + response::{IntoResponse, Response}, 106 + Json, 107 + }; 108 + 109 + impl IntoResponse for ApiError { 110 + fn into_response(self) -> Response { 111 + let status = StatusCode::from_u16(self.code.status_code()) 112 + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); 113 + 114 + match serde_json::to_vec(&ApiErrorEnvelope { error: self }) { 115 + Ok(body) => { 116 + (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() 117 + } 118 + Err(err) => { 119 + tracing::error!("failed to serialize ApiError: {err}"); 120 + ( 121 + StatusCode::INTERNAL_SERVER_ERROR, 122 + Json(serde_json::json!({ 123 + "error": { 124 + "code": "INTERNAL_SERVER_ERROR", 125 + "message": "internal error" 126 + } 127 + })), 128 + ) 129 + .into_response() 130 + } 131 + } 132 + } 87 133 } 88 134 } 89 135 ··· 128 174 fn omits_details_when_absent() { 129 175 let err = ApiError::new(ErrorCode::Forbidden, "access denied"); 130 176 let actual = serde_json::to_value(ApiErrorEnvelope { error: err }).unwrap(); 131 - // `details` key must not appear at all 132 177 assert!(!actual["error"].as_object().unwrap().contains_key("details")); 133 178 } 134 179 135 180 #[test] 136 181 fn status_code_mapping() { 137 182 let cases = [ 138 - (ErrorCode::InvalidClaim, StatusCode::BAD_REQUEST), 139 - (ErrorCode::Unauthorized, StatusCode::UNAUTHORIZED), 140 - (ErrorCode::TokenExpired, StatusCode::UNAUTHORIZED), 141 - (ErrorCode::Forbidden, StatusCode::FORBIDDEN), 142 - (ErrorCode::NotFound, StatusCode::NOT_FOUND), 143 - (ErrorCode::WeakPassword, StatusCode::UNPROCESSABLE_ENTITY), 144 - (ErrorCode::RateLimited, StatusCode::TOO_MANY_REQUESTS), 145 - (ErrorCode::ExportInProgress, StatusCode::SERVICE_UNAVAILABLE), 183 + (ErrorCode::InvalidClaim, 400u16), 184 + (ErrorCode::Unauthorized, 401), 185 + (ErrorCode::TokenExpired, 401), 186 + (ErrorCode::Forbidden, 403), 187 + (ErrorCode::NotFound, 404), 188 + (ErrorCode::WeakPassword, 422), 189 + (ErrorCode::RateLimited, 429), 190 + (ErrorCode::ExportInProgress, 503), 146 191 ]; 147 192 for (code, expected) in cases { 148 193 assert_eq!(code.status_code(), expected, "wrong status for {code:?}"); 194 + } 195 + } 196 + 197 + #[cfg(feature = "axum")] 198 + mod axum_tests { 199 + use super::*; 200 + use axum::http::StatusCode; 201 + use axum::response::IntoResponse; 202 + 203 + #[tokio::test] 204 + async fn into_response_correct_status_and_body() { 205 + let err = ApiError::new(ErrorCode::NotFound, "not found"); 206 + let response = err.into_response(); 207 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 208 + let body = axum::body::to_bytes(response.into_body(), usize::MAX) 209 + .await 210 + .unwrap(); 211 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 212 + assert_eq!(json["error"]["code"], "NOT_FOUND"); 213 + assert_eq!(json["error"]["message"], "not found"); 149 214 } 150 215 } 151 216 }
+2 -1
crates/relay/Cargo.toml
··· 11 11 path = "src/main.rs" 12 12 13 13 [dependencies] 14 - common = { workspace = true } 14 + axum = { workspace = true } 15 + common = { workspace = true, features = ["axum"] } 15 16 clap = { workspace = true } 16 17 anyhow = { workspace = true } 17 18 tracing = { workspace = true }