1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
pub use vti_common::error::*;
use axum::Json;
use axum::response::{IntoResponse, Response};
/// An error a handler answers with, which may carry the extended error code
/// its task's specification declares for it (SPEC §8.5, #1600).
///
/// The generic [`AppError`] rendering keeps an HTTP status and English, and on
/// the Trust Task path a bare `taskFailed` with a `details.reason` marker. What
/// neither carries is *which* of a task's declared outcomes this is, and that
/// is what a client branches on — "no such request" and "already decided" are
/// both a `404`/`409`-shaped miss to the status line and two different next
/// steps to the caller.
///
/// A [`Declared`](Self::Declared) error keeps everything the undeclared form
/// sends — the same status, the same `error` member, the same Trust Task
/// `details.reason` — and adds the code:
///
/// - REST: `{"error": "<display>", "code": "<slug>:<local>"}`, status unchanged,
/// so a client reading the status or the message is unaffected.
/// - Trust Task: the extended code in place of `taskFailed`, the marker kept
/// in `details.reason` so a client that does not know the code still
/// recovers the typed variant (SPEC §8.5 fallback).
///
/// `code` is always a generated `…::error_codes::X.code` — or, where the
/// task's specification declares nothing for the refusal, a framework standard
/// code from `trust_tasks_rs::StandardCode` (SPEC §8.3). Never a literal, and
/// never a code invented here: a refusal a specification does not describe is
/// one of the framework's, not a new name under the task's namespace.
#[derive(Debug)]
pub enum TaskError {
/// Answered as the service answers every other error.
App(AppError),
/// Answered with the task's declared code beside the usual rendering.
Declared { code: &'static str, error: AppError },
/// The operation needs the requester's passkey gesture bound to it, and
/// none is recorded yet. A ceremony is parked; on the Trust Task path the
/// refusal is `permissionDenied` with `request` inline as
/// `details.stepUpRequest` ([`crate::acl::bound_step_up::refusal_details`]).
/// A surface that cannot carry the request sees `error`, an
/// [`AppError::StepUpRequired`].
StepUp {
request: Box<crate::acl::bound_step_up::ApproveRequest>,
error: AppError,
},
}
impl TaskError {
/// `error`, answered with the declared `code`.
pub fn declared(code: &'static str, error: AppError) -> Self {
Self::Declared { code, error }
}
/// The refusal for an operation still waiting on its bound gesture.
pub fn step_up(request: Box<crate::acl::bound_step_up::ApproveRequest>) -> Self {
Self::StepUp {
request,
error: AppError::StepUpRequired(
"a passkey gesture bound to this operation is required".into(),
),
}
}
/// The declared code, if this error carries one.
pub fn code(&self) -> Option<&'static str> {
match self {
Self::App(_) | Self::StepUp { .. } => None,
Self::Declared { code, .. } => Some(code),
}
}
/// The underlying error, whichever form this is.
pub fn app_error(&self) -> &AppError {
match self {
Self::App(e) | Self::Declared { error: e, .. } | Self::StepUp { error: e, .. } => e,
}
}
}
impl From<AppError> for TaskError {
fn from(e: AppError) -> Self {
Self::App(e)
}
}
/// Dropping the code: a surface that cannot carry one (the legacy DIDComm
/// problem-report path) sees exactly the error it saw before the code existed.
impl From<TaskError> for AppError {
fn from(e: TaskError) -> Self {
match e {
TaskError::App(e)
| TaskError::Declared { error: e, .. }
| TaskError::StepUp { error: e, .. } => e,
}
}
}
impl std::fmt::Display for TaskError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.app_error().fmt(f)
}
}
impl IntoResponse for TaskError {
fn into_response(self) -> Response {
match self {
Self::App(e) | Self::StepUp { error: e, .. } => e.into_response(),
Self::Declared { code, error } => {
let message = error.to_string();
// The status (and the server-side log line) come from the
// error's own rendering, so declaring a code can never move a
// route's status.
let status = error.into_response().status();
(
status,
Json(serde_json::json!({ "error": message, "code": code })),
)
.into_response()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use axum::http::StatusCode;
#[tokio::test]
async fn a_declared_error_keeps_the_status_and_message_and_adds_the_code() {
let plain = AppError::NotFound("join request not found: x".into()).into_response();
let plain_status = plain.status();
let plain_body: serde_json::Value =
serde_json::from_slice(&to_bytes(plain.into_body(), 4096).await.unwrap()).unwrap();
let code =
trust_tasks_rs::specs::vtc::join_requests::show::v0_1::error_codes::NOT_FOUND.code;
let declared =
TaskError::declared(code, AppError::NotFound("join request not found: x".into()))
.into_response();
assert_eq!(declared.status(), plain_status);
assert_eq!(declared.status(), StatusCode::NOT_FOUND);
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(declared.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["error"], plain_body["error"]);
assert_eq!(body["code"], code);
}
}