Skip to main content

ephemeris_forge_github/api/
error.rs

1use ephemeris_forge::{EmptyUserName, RequestLabel, TransportError, summarize};
2use facet::Facet;
3
4use crate::config::{ConfigError, RulesetName};
5
6/// Anything that stops a GitHub request from producing a usable answer.
7#[derive(Debug, thiserror::Error)]
8pub enum ForgeError {
9    /// Transport trouble: unreachable, a bad credential, a missing resource,
10    /// a rate limit, a rejected body, or a body this build cannot read.
11    #[error(transparent)]
12    Transport(TransportError),
13    /// GitHub answered 422: it read the request and found it invalid.
14    #[error("{request} was rejected as unprocessable{}", body.summary())]
15    Unprocessable {
16        /// The method and path that were attempted.
17        request: RequestLabel,
18        /// What GitHub answered with.
19        body: RejectionBody,
20    },
21    /// The repository or plan lacks the capability the route needs (for
22    /// example, repository rulesets).
23    #[error("{request} needs a capability this repository does not have")]
24    CapabilityUnavailable {
25        /// The method and path that were attempted.
26        request: RequestLabel,
27    },
28    /// The configuration has a value GitHub would refuse.
29    #[error(transparent)]
30    Config(#[from] ConfigError),
31    /// A ruleset lets the acting App bypass it, but the caller is not an App.
32    #[error(
33        "ruleset {ruleset} names the acting App as a bypass actor, but the caller is not an App"
34    )]
35    OwnAppBypassWithoutApp {
36        /// The ruleset's name.
37        ruleset: RulesetName,
38    },
39    /// A rule in a live ruleset has no readable type.
40    #[error("a rule of ruleset {ruleset:?} cannot be read")]
41    RuleDecode {
42        /// The ruleset's name.
43        ruleset: String,
44        /// Why the rule did not decode.
45        #[source]
46        source: Box<facet_value::ValueError>,
47    },
48    /// The parameters of a rule in a live ruleset do not decode.
49    #[error("the {rule} rule of ruleset {ruleset:?} has parameters that cannot be read")]
50    RuleParametersDecode {
51        /// The ruleset's name.
52        ruleset: String,
53        /// The rule's type.
54        rule: String,
55        /// Why the parameters did not decode.
56        #[source]
57        source: Box<facet_value::ValueError>,
58    },
59    /// A pending invitation cannot be changed to a custom role.
60    #[error("a pending invitation cannot be changed to a custom role")]
61    InvitationRoleUnsupported,
62    /// More than one open pull request matched the proposal's head and base.
63    #[error("multiple open pull requests use head {head:?} and base {base:?}")]
64    AmbiguousPullRequest {
65        /// The proposal's head branch.
66        head: String,
67        /// The proposal's base branch.
68        base: String,
69    },
70    /// A release asset filename cannot address a safe upload path.
71    #[error("release asset filename {filename:?} is invalid")]
72    InvalidAssetFilename {
73        /// The rejected filename.
74        filename: String,
75    },
76    /// The release is already published, so it is immutable.
77    #[error("release {id} is already published and immutable")]
78    ReleaseImmutable {
79        /// The release's id.
80        id: u64,
81    },
82    /// The release's upload URL template does not parse as a URL.
83    #[error("release {id}'s upload URL {template:?} does not parse")]
84    InvalidUploadUrl {
85        /// The release's id.
86        id: u64,
87        /// The upload URL template GitHub answered with.
88        template: String,
89    },
90    /// The forge answered the current user request with an empty login.
91    #[error("the forge answered the current user request with an empty login")]
92    EmptyCallerLogin(#[from] EmptyUserName),
93}
94
95/// What GitHub answered a rejected request with.
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub enum RejectionBody {
98    /// The body decodes as an error object.
99    Decoded(Rejection),
100    /// The body does not decode. This is the summarized raw body.
101    Raw(String),
102}
103
104/// The error body GitHub answers a rejected request with.
105#[derive(Clone, Debug, Eq, Facet, PartialEq)]
106pub struct Rejection {
107    /// The summary, for example `Validation Failed`.
108    pub message: Option<String>,
109    /// The problems GitHub found.
110    #[facet(default)]
111    pub errors: Vec<RejectionDetail>,
112}
113
114/// One problem in a [`Rejection`]. GitHub sends an object or a plain string,
115/// depending on the endpoint.
116#[derive(Clone, Debug, Eq, Facet, PartialEq)]
117#[repr(u8)]
118#[facet(untagged)]
119pub enum RejectionDetail {
120    /// A problem with the resource, field and code.
121    Field(FieldRejection),
122    /// A problem described by one string.
123    Text(String),
124}
125
126/// A problem in object form.
127#[derive(Clone, Debug, Eq, Facet, PartialEq)]
128pub struct FieldRejection {
129    /// The kind of resource the problem concerns.
130    pub resource: Option<String>,
131    /// The field that is invalid.
132    pub field: Option<String>,
133    /// The machine readable problem, for example `missing_field`.
134    pub code: Option<String>,
135    /// A readable description.
136    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    /// The HTTP status of a failure that carries one.
189    #[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}