ephemeris_forge_github/api/
error.rs1use ephemeris_forge::{EmptyUserName, RequestLabel, TransportError, summarize};
2use facet::Facet;
3
4use crate::config::{ConfigError, RulesetName};
5
6#[derive(Debug, thiserror::Error)]
8pub enum ForgeError {
9 #[error(transparent)]
12 Transport(TransportError),
13 #[error("{request} was rejected as unprocessable{}", body.summary())]
15 Unprocessable {
16 request: RequestLabel,
18 body: RejectionBody,
20 },
21 #[error("{request} needs a capability this repository does not have")]
24 CapabilityUnavailable {
25 request: RequestLabel,
27 },
28 #[error(transparent)]
30 Config(#[from] ConfigError),
31 #[error(
33 "ruleset {ruleset} names the acting App as a bypass actor, but the caller is not an App"
34 )]
35 OwnAppBypassWithoutApp {
36 ruleset: RulesetName,
38 },
39 #[error("a rule of ruleset {ruleset:?} cannot be read")]
41 RuleDecode {
42 ruleset: String,
44 #[source]
46 source: Box<facet_value::ValueError>,
47 },
48 #[error("the {rule} rule of ruleset {ruleset:?} has parameters that cannot be read")]
50 RuleParametersDecode {
51 ruleset: String,
53 rule: String,
55 #[source]
57 source: Box<facet_value::ValueError>,
58 },
59 #[error("a pending invitation cannot be changed to a custom role")]
61 InvitationRoleUnsupported,
62 #[error("multiple open pull requests use head {head:?} and base {base:?}")]
64 AmbiguousPullRequest {
65 head: String,
67 base: String,
69 },
70 #[error("release asset filename {filename:?} is invalid")]
72 InvalidAssetFilename {
73 filename: String,
75 },
76 #[error("release {id} is already published and immutable")]
78 ReleaseImmutable {
79 id: u64,
81 },
82 #[error("release {id}'s upload URL {template:?} does not parse")]
84 InvalidUploadUrl {
85 id: u64,
87 template: String,
89 },
90 #[error("the forge answered the current user request with an empty login")]
92 EmptyCallerLogin(#[from] EmptyUserName),
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
97pub enum RejectionBody {
98 Decoded(Rejection),
100 Raw(String),
102}
103
104#[derive(Clone, Debug, Eq, Facet, PartialEq)]
106pub struct Rejection {
107 pub message: Option<String>,
109 #[facet(default)]
111 pub errors: Vec<RejectionDetail>,
112}
113
114#[derive(Clone, Debug, Eq, Facet, PartialEq)]
117#[repr(u8)]
118#[facet(untagged)]
119pub enum RejectionDetail {
120 Field(FieldRejection),
122 Text(String),
124}
125
126#[derive(Clone, Debug, Eq, Facet, PartialEq)]
128pub struct FieldRejection {
129 pub resource: Option<String>,
131 pub field: Option<String>,
133 pub code: Option<String>,
135 pub message: Option<String>,
137}
138
139impl From<TransportError> for ForgeError {
140 fn from(error: TransportError) -> Self {
141 match error {
142 TransportError::Rejected {
143 status: 422,
144 body,
145 request,
146 } => Self::Unprocessable {
147 request,
148 body: RejectionBody::new(&body),
149 },
150 other @ (TransportError::InvalidPath { path: _, source: _ }
151 | TransportError::Unreachable {
152 request: _,
153 source: _,
154 }
155 | TransportError::CredentialRejected { request: _ }
156 | TransportError::NotFound { request: _ }
157 | TransportError::RateLimited {
158 request: _,
159 wait: _,
160 }
161 | TransportError::RateLimitWaitTooLong {
162 request: _,
163 wait: _,
164 }
165 | TransportError::Rejected {
166 request: _,
167 status: _,
168 body: _,
169 }
170 | TransportError::Unexpected {
171 request: _,
172 status: _,
173 body: _,
174 }
175 | TransportError::TooManyPages {
176 request: _,
177 limit: _,
178 }
179 | TransportError::Malformed {
180 request: _,
181 source: _,
182 }) => Self::Transport(other),
183 }
184 }
185}
186
187impl ForgeError {
188 #[must_use]
190 pub fn status(&self) -> Option<u16> {
191 if let Self::Transport(transport) = self {
192 return transport.status();
193 }
194 matches!(
195 self,
196 Self::Unprocessable {
197 request: _,
198 body: _
199 }
200 )
201 .then_some(422)
202 }
203}
204
205impl RejectionBody {
206 fn new(body: &str) -> Self {
207 facet_json::from_str(body).map_or_else(|_| Self::Raw(summarize(body)), Self::Decoded)
208 }
209
210 fn summary(&self) -> String {
211 let error = match self {
212 Self::Decoded(error) => error,
213 Self::Raw(body) => return format!(": {body}"),
214 };
215 let mut text = String::new();
216 if let Some(message) = &error.message {
217 text.push_str(": ");
218 text.push_str(message);
219 }
220 for detail in &error.errors {
221 let parts: Vec<&str> = match detail {
222 RejectionDetail::Field(detail) => [&detail.field, &detail.code, &detail.message]
223 .into_iter()
224 .flatten()
225 .map(String::as_str)
226 .collect(),
227 RejectionDetail::Text(line) => vec![line],
228 };
229 text.push_str(" (");
230 text.push_str(&parts.join(" "));
231 text.push(')');
232 }
233 text
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use ephemeris_forge::{RequestLabel, TransportError};
240 use reqwest::Method;
241
242 use super::{ForgeError, RejectionBody, RejectionDetail};
243
244 #[test]
245 fn a_422_response_keeps_the_request_and_the_decoded_error() {
246 let error: ForgeError = TransportError::Rejected {
247 request: RequestLabel::new(&Method::PATCH, "/repos/acme/demo"),
248 status: 422,
249 body: r#"{"message":"Validation Failed","errors":[{"resource":"Repository","field":"name","code":"already_exists"}]}"#.to_owned(),
250 }
251 .into();
252 let ForgeError::Unprocessable {
253 request,
254 body: RejectionBody::Decoded(error),
255 } = error
256 else {
257 panic!("expected an unprocessable error");
258 };
259 assert_eq!(request.to_string(), "PATCH /repos/acme/demo");
260 assert_eq!(error.message.as_deref(), Some("Validation Failed"));
261 let RejectionDetail::Field(detail) = &error.errors[0] else {
262 panic!("expected an object detail");
263 };
264 assert_eq!(detail.field.as_deref(), Some("name"));
265 assert_eq!(detail.code.as_deref(), Some("already_exists"));
266 }
267
268 #[test]
269 fn a_422_with_string_details_keeps_them() {
270 let error: ForgeError = TransportError::Rejected {
271 request: RequestLabel::new(&Method::PUT, "/x"),
272 status: 422,
273 body: r#"{"message":"Validation Failed","errors":["Reviewers must be collaborators",{"field":"name"}]}"#.to_owned(),
274 }
275 .into();
276 assert_eq!(
277 error.to_string(),
278 "PUT /x was rejected as unprocessable: Validation Failed (Reviewers must be \
279 collaborators) (name)"
280 );
281 }
282
283 #[test]
284 fn a_422_body_that_is_not_an_error_object_still_keeps_the_request() {
285 let error: ForgeError = TransportError::Rejected {
286 request: RequestLabel::new(&Method::GET, "/x"),
287 status: 422,
288 body: "nope".to_owned(),
289 }
290 .into();
291 assert!(matches!(
292 &error,
293 ForgeError::Unprocessable { request, body: RejectionBody::Raw(body) }
294 if request.to_string() == "GET /x" && body == "nope"
295 ));
296 assert_eq!(
297 error.to_string(),
298 "GET /x was rejected as unprocessable: nope"
299 );
300 }
301
302 #[test]
303 fn other_statuses_stay_transport_failures() {
304 let error: ForgeError = TransportError::NotFound {
305 request: RequestLabel::new(&Method::GET, "/x"),
306 }
307 .into();
308 assert!(matches!(error, ForgeError::Transport(_)));
309 }
310}