forgejo_api/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! Bindings for Forgejo's web API. See the [`Forgejo`] struct for how to get started.
4//!
5//! Every endpoint that Forgejo exposes under `/api/v1/` is included here as a
6//! method on the [`Forgejo`] struct. They are generated from [Forgejo's OpenAPI
7//! document](https://code.forgejo.org/api/swagger).
8//!
9//! Start by connecting to the API with [`Forgejo::new`]
10//!
11//! ```no_run
12//! # use forgejo_api::{Forgejo, Auth};
13//! # fn foo() -> Result<(), Box<dyn std::error::Error>> {
14//! let api = Forgejo::new(
15//!     Auth::None, // More info on authentication below
16//!     url::Url::parse("https://forgejo.example.local")?,
17//! )?;
18//! # Ok(())
19//! # }
20//! ```
21//!
22//! Then use that to interact with Forgejo!
23//!
24//! ```no_run
25//! # use forgejo_api::{Forgejo, Auth, structs::IssueListIssuesQuery};
26//! # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
27//! # let api = Forgejo::new(
28//! #     Auth::None, // More info on authentication below
29//! #     url::Url::parse("https://forgejo.example.local")?,
30//! # )?;
31//! // Loads every issue made by "someone" on that repo!
32//! let issues = api.issue_list_issues(
33//!         "example-user",
34//!         "example-repo",
35//!         IssueListIssuesQuery {
36//!             created_by: Some("someone".into()),
37//!             ..Default::default()
38//!         }
39//!     )
40//!     .all()
41//!     .await?;
42//! # Ok(())
43//! # }
44//! ```
45//!
46//! ## Authentication
47//!
48//! Credentials are given in the [`Auth`] type when connecting to Forgejo, in
49//! [`Forgejo::new`] or [`Forgejo::with_user_agent`]. Forgejo supports
50//! authenticating via username & password, application tokens, or OAuth, and
51//! those are all available here.
52//!
53//! Provided credentials are always sent in the `Authorization` header. Sending
54//! credentials in a query parameter is deprecated in Forgejo and so is not
55//! supported here.
56//!
57//! ```no_run
58//! # use forgejo_api::{Forgejo, Auth};
59//! # fn foo() -> Result<(), Box<dyn std::error::Error>> {
60//! // No authentication
61//! // No credentials are sent, only public endpoints are available
62//! let api = Forgejo::new(
63//!     Auth::None,
64//!     url::Url::parse("https://forgejo.example.local")?,
65//! )?;
66//!
67//! // Application Token
68//! // Provides access depending on the scopes set when the token is created
69//! let api = Forgejo::new(
70//!     Auth::Token("2a3684d663cd9fdef3a9d759492c21844429e0f9"),
71//!     url::Url::parse("https://forgejo.example.local")?,
72//! )?;
73//!
74//! // OAuth2 Token
75//! // Provides complete access to the user's account
76//! let api = Forgejo::new(
77//!     Auth::OAuth2("Pretend there's a token here (they're really long!)"),
78//!     url::Url::parse("https://forgejo.example.local")?,
79//! )?;
80//!
81//! // Username & password
82//! // Provides complete access to the user's account
83//! //
84//! // I recommended only using this to create a new application token with
85//! // `.user_create_token()`, and to use that token for further operations.
86//! // Storing passwords is tricky!
87//! let api = Forgejo::new(
88//!     Auth::Password {
89//!         username: "ExampleUser",
90//!         password: "password123", // I hope your password is more secure than this...
91//!         mfa: None, // If the user has 2FA enable, it has to be included here.
92//!     },
93//!     url::Url::parse("https://forgejo.example.local")?,
94//! )?;
95//! # Ok(())
96//! # }
97//! ```
98//!
99//! ## Pagination
100//!
101//! Endpoints that return lists of items send them one page of results at a
102//! time. In Forgejo's API spec, the `path` and `limit` parameters are used to
103//! set what page to return and how many items should be included per page
104//! (respectively). Since they're so common, these parameters aren't included
105//! in function args like most parameters. They are instead available as the
106//! `.page(n)` and `.page_size(n)` methods on requests.
107//!
108//! For example:
109//! ```no_run
110//! # use forgejo_api::{Forgejo, Auth};
111//! # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
112//! # let api = Forgejo::new(
113//! #    Auth::None,
114//! #    url::Url::parse("https://unimportant/")?,
115//! # )?;
116//! let following = api.user_current_list_following()
117//!     // The third page (pages are 1-indexed)
118//!     .page(3)
119//!     // Return 30 items per page. It's possible it will return fewer, if
120//!     // there aren't enough to fill the page
121//!     .page_size(30)
122//!     .await?;
123//! # Ok(())
124//! # }
125//! ```
126//!
127//! There are helper functions to make this easier as well:
128//!
129//! - **`.stream()`**:
130//!   
131//!   Returns a `Stream` that yields one item at a time, automatically incrementing
132//!   the page number as needed. (i.e. `.issue_list_issues().stream()` yields `Issue`)
133//! - **`.stream_pages()`**;
134//!   
135//!   Returns a `Stream` that yields one page of items at a
136//!   time. (i.e. `.issue_list_issues().stream_pages()` yields `Vec<Issue>`)
137//!
138//!   Useful for endpoints that return more than just a `Vec<T>` (such as `repo_search`)
139//! - **`.all()`**;
140//!   
141//!   Returns a `Vec` of every item requested. Equivalent to
142//!   `.stream().try_collect()`.
143
144use std::{
145    borrow::Cow, collections::BTreeMap, future::Future, marker::PhantomData, pin::Pin, task::Poll,
146};
147
148use reqwest::{Client, StatusCode};
149use serde::{Deserialize, Deserializer};
150use soft_assert::*;
151use url::Url;
152use zeroize::Zeroize;
153
154/// An `async` client for Forgejo's web API. For a blocking client, see [`sync::Forgejo`]
155///
156/// For more info on how to use this, see the [crate level docs](crate)
157pub struct Forgejo {
158    url: Url,
159    client: Client,
160}
161
162mod generated;
163#[cfg(feature = "sync")]
164pub mod sync;
165
166#[derive(thiserror::Error, Debug)]
167pub enum ForgejoError {
168    #[error("url must have a host")]
169    HostRequired,
170    #[error("scheme must be http or https")]
171    HttpRequired,
172    #[error(transparent)]
173    ReqwestError(#[from] reqwest::Error),
174    #[error("API key should be ascii")]
175    KeyNotAscii,
176    #[error("the response from forgejo was not properly structured")]
177    BadStructure(#[from] StructureError),
178    #[error("unexpected status code {} {}", .0.as_u16(), .0.canonical_reason().unwrap_or(""))]
179    UnexpectedStatusCode(StatusCode),
180    #[error(transparent)]
181    ApiError(#[from] ApiError),
182    #[error("the provided authorization was too long to accept")]
183    AuthTooLong,
184}
185
186#[derive(thiserror::Error, Debug)]
187pub enum StructureError {
188    #[error("{e}")]
189    Serde {
190        e: serde_json::Error,
191        contents: bytes::Bytes,
192    },
193    #[error(transparent)]
194    Utf8(#[from] std::str::Utf8Error),
195    #[error("failed to find header `{0}`")]
196    HeaderMissing(&'static str),
197    #[error("header was not ascii")]
198    HeaderNotAscii,
199    #[error("failed to parse header")]
200    HeaderParseFailed,
201    #[error("nothing was returned when a value was expected")]
202    EmptyResponse,
203}
204
205impl From<std::str::Utf8Error> for ForgejoError {
206    fn from(error: std::str::Utf8Error) -> Self {
207        Self::BadStructure(StructureError::Utf8(error))
208    }
209}
210
211#[derive(thiserror::Error, Debug)]
212pub struct ApiError {
213    pub message: Option<String>,
214    pub kind: ApiErrorKind,
215}
216
217impl ApiError {
218    fn new(message: Option<String>, kind: ApiErrorKind) -> Self {
219        Self { message, kind }
220    }
221
222    pub fn message(&self) -> Option<&str> {
223        self.message.as_deref()
224    }
225
226    pub fn error_kind(&self) -> &ApiErrorKind {
227        &self.kind
228    }
229}
230
231impl From<ApiErrorKind> for ApiError {
232    fn from(kind: ApiErrorKind) -> Self {
233        Self {
234            message: None,
235            kind,
236        }
237    }
238}
239
240impl std::fmt::Display for ApiError {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        match &self.message {
243            Some(message) => write!(f, "{}: {message}", self.kind),
244            None => write!(f, "{}", self.kind),
245        }
246    }
247}
248
249#[derive(thiserror::Error, Debug)]
250pub enum ApiErrorKind {
251    #[error("api error")]
252    Generic,
253    #[error("access denied")]
254    Forbidden,
255    #[error("invalid topics")]
256    InvalidTopics { invalid_topics: Option<Vec<String>> },
257    #[error("not found")]
258    NotFound { errors: Option<Vec<String>> },
259    #[error("repo archived")]
260    RepoArchived,
261    #[error("unauthorized")]
262    Unauthorized,
263    #[error("validation failed")]
264    ValidationFailed,
265    #[error("status code {0}")]
266    Other(reqwest::StatusCode),
267}
268
269impl From<structs::APIError> for ApiError {
270    fn from(value: structs::APIError) -> Self {
271        Self::new(value.message, ApiErrorKind::Generic)
272    }
273}
274impl From<structs::APIForbiddenError> for ApiError {
275    fn from(value: structs::APIForbiddenError) -> Self {
276        Self::new(value.message, ApiErrorKind::Forbidden)
277    }
278}
279impl From<structs::APIInvalidTopicsError> for ApiError {
280    fn from(value: structs::APIInvalidTopicsError) -> Self {
281        Self::new(
282            value.message,
283            ApiErrorKind::InvalidTopics {
284                invalid_topics: value.invalid_topics,
285            },
286        )
287    }
288}
289impl From<structs::APINotFound> for ApiError {
290    fn from(value: structs::APINotFound) -> Self {
291        Self::new(
292            value.message,
293            ApiErrorKind::NotFound {
294                errors: value.errors,
295            },
296        )
297    }
298}
299impl From<structs::APIRepoArchivedError> for ApiError {
300    fn from(value: structs::APIRepoArchivedError) -> Self {
301        Self::new(value.message, ApiErrorKind::RepoArchived)
302    }
303}
304impl From<structs::APIUnauthorizedError> for ApiError {
305    fn from(value: structs::APIUnauthorizedError) -> Self {
306        Self::new(value.message, ApiErrorKind::Unauthorized)
307    }
308}
309impl From<structs::APIValidationError> for ApiError {
310    fn from(value: structs::APIValidationError) -> Self {
311        Self::new(value.message, ApiErrorKind::ValidationFailed)
312    }
313}
314impl From<reqwest::StatusCode> for ApiError {
315    fn from(value: reqwest::StatusCode) -> Self {
316        match value {
317            reqwest::StatusCode::NOT_FOUND => ApiErrorKind::NotFound { errors: None },
318            reqwest::StatusCode::FORBIDDEN => ApiErrorKind::Forbidden,
319            reqwest::StatusCode::UNAUTHORIZED => ApiErrorKind::Unauthorized,
320            _ => ApiErrorKind::Other(value),
321        }
322        .into()
323    }
324}
325impl From<OAuthError> for ApiError {
326    fn from(value: OAuthError) -> Self {
327        Self::new(Some(value.error_description), ApiErrorKind::Generic)
328    }
329}
330
331/// Method of authentication to connect to the Forgejo host with.
332pub enum Auth<'a> {
333    /// Application Access Token. Grants access to scope enabled for the
334    /// provided token, which may include full access.
335    ///
336    /// To learn how to create a token, see
337    /// [the Codeberg docs on the subject](https://docs.codeberg.org/advanced/access-token/).
338    ///
339    /// To learn about token scope, see
340    /// [the official Forgejo docs](https://forgejo.org/docs/latest/user/token-scope/).
341    Token(&'a str),
342    /// OAuth2 Token. Grants full access to the user's account, except for
343    /// creating application access tokens.
344    ///
345    /// To learn how to create an OAuth2 token, see
346    /// [the official Forgejo docs on the subject](https://forgejo.org/docs/latest/user/oauth2-provider).
347    OAuth2(&'a str),
348    /// Username, password, and 2-factor auth code (if enabled). Grants full
349    /// access to the user's account.
350    Password {
351        username: &'a str,
352        password: &'a str,
353        mfa: Option<&'a str>,
354    },
355    /// No authentication. Only grants access to access public endpoints.
356    None,
357}
358
359impl Auth<'_> {
360    fn to_headers(&self) -> Result<reqwest::header::HeaderMap, ForgejoError> {
361        let mut headers = reqwest::header::HeaderMap::new();
362        match self {
363            Auth::Token(token) => {
364                let mut header: reqwest::header::HeaderValue = format!("token {token}")
365                    .try_into()
366                    .map_err(|_| ForgejoError::KeyNotAscii)?;
367                header.set_sensitive(true);
368                headers.insert("Authorization", header);
369            }
370            Auth::Password {
371                username,
372                password,
373                mfa,
374            } => {
375                let unencoded_len = username.len() + password.len() + 1;
376                let unpadded_len = unencoded_len
377                    .checked_mul(4)
378                    .ok_or(ForgejoError::AuthTooLong)?
379                    .div_ceil(3);
380                // round up to next multiple of 4, to account for padding
381                let len = unpadded_len.div_ceil(4) * 4;
382                let mut bytes = vec![0; len];
383
384                // panic safety: len cannot be zero
385                let mut encoder = base64ct::Encoder::<base64ct::Base64>::new(&mut bytes).unwrap();
386
387                // panic safety: len will always be enough
388                encoder.encode(username.as_bytes()).unwrap();
389                encoder.encode(b":").unwrap();
390                encoder.encode(password.as_bytes()).unwrap();
391
392                let b64 = encoder.finish().unwrap();
393
394                let mut header: reqwest::header::HeaderValue =
395                    format!("Basic {b64}").try_into().unwrap(); // panic safety: base64 is always ascii
396                header.set_sensitive(true);
397                headers.insert("Authorization", header);
398
399                bytes.zeroize();
400
401                if let Some(mfa) = mfa {
402                    let mut key_header: reqwest::header::HeaderValue =
403                        (*mfa).try_into().map_err(|_| ForgejoError::KeyNotAscii)?;
404                    key_header.set_sensitive(true);
405                    headers.insert("X-FORGEJO-OTP", key_header);
406                }
407            }
408            Auth::OAuth2(token) => {
409                let mut header: reqwest::header::HeaderValue = format!("Bearer {token}")
410                    .try_into()
411                    .map_err(|_| ForgejoError::KeyNotAscii)?;
412                header.set_sensitive(true);
413                headers.insert("Authorization", header);
414            }
415            Auth::None => (),
416        }
417        Ok(headers)
418    }
419}
420
421impl Forgejo {
422    /// Create a new client connect to the API of the specified Forgejo instance.
423    ///
424    /// The default user agent is "forgejo-api-rs". Use
425    /// [`Forgejo::with_user_agent`] to set a custom one.
426    pub fn new(auth: Auth, url: Url) -> Result<Self, ForgejoError> {
427        Self::with_user_agent(auth, url, "forgejo-api-rs")
428    }
429
430    /// Just like [`Forgejo::new`], but includes a custom user agent to be sent
431    /// with each request.
432    pub fn with_user_agent(auth: Auth, url: Url, user_agent: &str) -> Result<Self, ForgejoError> {
433        soft_assert!(
434            matches!(url.scheme(), "http" | "https"),
435            Err(ForgejoError::HttpRequired)
436        );
437
438        let client = Client::builder()
439            .user_agent(user_agent)
440            .default_headers(auth.to_headers()?)
441            .build()?;
442        Ok(Self { url, client })
443    }
444
445    pub async fn download_release_attachment(
446        &self,
447        owner: &str,
448        repo: &str,
449        release: i64,
450        attach: i64,
451    ) -> Result<bytes::Bytes, ForgejoError> {
452        let release = self
453            .repo_get_release_attachment(owner, repo, release, attach)
454            .await?;
455        let mut url = self.url.clone();
456        url.path_segments_mut()
457            .unwrap()
458            .pop_if_empty()
459            .extend(["attachments", &release.uuid.unwrap().to_string()]);
460        let request = self.client.get(url).build()?;
461        Ok(self.client.execute(request).await?.bytes().await?)
462    }
463
464    /// Requests a new OAuth2 access token
465    ///
466    /// More info at [Forgejo's docs](https://forgejo.org/docs/latest/user/oauth2-provider).
467    pub async fn oauth_get_access_token(
468        &self,
469        body: structs::OAuthTokenRequest<'_>,
470    ) -> Result<structs::OAuthToken, ForgejoError> {
471        let url = self.url.join("login/oauth/access_token").unwrap();
472        let request = self.client.post(url).json(&body).build()?;
473        let response = self.client.execute(request).await?;
474        match response.status() {
475            reqwest::StatusCode::OK => Ok(response.json().await?),
476            status if status.is_client_error() => {
477                let err = response.json::<OAuthError>().await?;
478                Err(ApiError::from(err).into())
479            }
480            _ => Err(ForgejoError::UnexpectedStatusCode(response.status())),
481        }
482    }
483
484    pub async fn send_request(&self, request: &RawRequest) -> Result<ApiResponse, ForgejoError> {
485        let mut url = self
486            .url
487            .join(&request.path)
488            .expect("url fail. bug in forgejo-api");
489
490        // Block needed to contain the scope of query_pairs
491        // Otherwise it prevents the returned Futured from being Send
492        // Oddly, `drop(query_pairs)` doesn't work for this.
493        {
494            let mut query_pairs = url.query_pairs_mut();
495            if let Some(query) = &request.query {
496                query_pairs.extend_pairs(query.iter());
497            }
498            if let Some(page) = request.page {
499                query_pairs.append_pair("page", &format!("{page}"));
500            }
501            if let Some(limit) = request.limit {
502                query_pairs.append_pair("limit", &format!("{limit}"));
503            }
504        }
505
506        let mut reqwest_request = self.client.request(request.method.clone(), url);
507        reqwest_request = match &request.body {
508            RequestBody::Json(bytes) => reqwest_request
509                .body(bytes.clone())
510                .header(reqwest::header::CONTENT_TYPE, "application/json"),
511            RequestBody::Form(list) => {
512                let mut form = reqwest::multipart::Form::new();
513                for (k, v) in list {
514                    form = form.part(
515                        *k,
516                        reqwest::multipart::Part::bytes(v.clone()).file_name("file"),
517                    );
518                }
519                reqwest_request.multipart(form)
520            }
521            RequestBody::None => reqwest_request,
522        };
523        let mut reqwest_response = reqwest_request.send().await?;
524        let response = ApiResponse {
525            status_code: reqwest_response.status(),
526            headers: std::mem::take(reqwest_response.headers_mut()),
527            body: reqwest_response.bytes().await?,
528        };
529        Ok(response)
530    }
531
532    pub async fn hit_endpoint<E: Endpoint, R: FromResponse>(
533        &self,
534        endpoint: E,
535    ) -> Result<R, ForgejoError> {
536        let (response, has_body) =
537            E::handle_error(self.send_request(&endpoint.make_request()).await?)?;
538        Ok(R::from_response(response, has_body)?)
539    }
540}
541
542#[derive(serde::Deserialize)]
543struct OAuthError {
544    error_description: String,
545    // intentionally ignored, no need for now
546    // url: Url
547}
548
549pub mod structs {
550    pub use crate::generated::structs::*;
551
552    /// A Request for a new OAuth2 access token
553    ///
554    /// More info at [Forgejo's docs](https://forgejo.org/docs/latest/user/oauth2-provider).
555    #[derive(serde::Serialize)]
556    #[serde(tag = "grant_type")]
557    pub enum OAuthTokenRequest<'a> {
558        /// Request for getting an access code for a confidential app
559        ///
560        /// The `code` field must have come from sending the user to
561        /// `/login/oauth/authorize` in their browser
562        #[serde(rename = "authorization_code")]
563        Confidential {
564            client_id: &'a str,
565            client_secret: &'a str,
566            code: &'a str,
567            redirect_uri: url::Url,
568        },
569        /// Request for getting an access code for a public app
570        ///
571        /// The `code` field must have come from sending the user to
572        /// `/login/oauth/authorize` in their browser
573        #[serde(rename = "authorization_code")]
574        Public {
575            client_id: &'a str,
576            code_verifier: &'a str,
577            code: &'a str,
578            redirect_uri: url::Url,
579        },
580        /// Request for refreshing an access code
581        #[serde(rename = "refresh_token")]
582        Refresh {
583            refresh_token: &'a str,
584            client_id: &'a str,
585            client_secret: &'a str,
586        },
587    }
588
589    #[derive(serde::Deserialize)]
590    pub struct OAuthToken {
591        pub access_token: String,
592        pub refresh_token: String,
593        pub token_type: String,
594        /// Number of seconds until the access token expires.
595        pub expires_in: u32,
596    }
597}
598
599// Forgejo can return blank strings for URLs. This handles that by deserializing
600// that as `None`
601fn none_if_blank_url<'de, D: serde::Deserializer<'de>>(
602    deserializer: D,
603) -> Result<Option<Url>, D::Error> {
604    use serde::de::{Error, Unexpected, Visitor};
605    use std::fmt;
606
607    struct EmptyUrlVisitor;
608
609    impl<'de> Visitor<'de> for EmptyUrlVisitor {
610        type Value = Option<Url>;
611
612        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
613            formatter.write_str("option")
614        }
615
616        #[inline]
617        fn visit_unit<E>(self) -> Result<Self::Value, E>
618        where
619            E: Error,
620        {
621            Ok(None)
622        }
623
624        #[inline]
625        fn visit_none<E>(self) -> Result<Self::Value, E>
626        where
627            E: Error,
628        {
629            Ok(None)
630        }
631
632        #[inline]
633        fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
634        where
635            D: serde::Deserializer<'de>,
636        {
637            let s: String = serde::Deserialize::deserialize(deserializer)?;
638            if s.is_empty() {
639                return Ok(None);
640            }
641            Url::parse(&s)
642                .map_err(|err| {
643                    let err_s = format!("{}", err);
644                    Error::invalid_value(Unexpected::Str(&s), &err_s.as_str())
645                })
646                .map(Some)
647        }
648
649        #[inline]
650        fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
651        where
652            E: Error,
653        {
654            if s.is_empty() {
655                return Ok(None);
656            }
657            Url::parse(s)
658                .map_err(|err| {
659                    let err_s = format!("{err}");
660                    Error::invalid_value(Unexpected::Str(s), &err_s.as_str())
661                })
662                .map(Some)
663        }
664    }
665
666    deserializer.deserialize_option(EmptyUrlVisitor)
667}
668
669#[allow(dead_code)] // not used yet, but it might appear in the future
670fn deserialize_ssh_url<'de, D, DE>(deserializer: D) -> Result<Url, DE>
671where
672    D: Deserializer<'de>,
673    DE: serde::de::Error,
674{
675    let raw_url: String = String::deserialize(deserializer).map_err(DE::custom)?;
676    parse_ssh_url(&raw_url).map_err(DE::custom)
677}
678
679fn deserialize_optional_ssh_url<'de, D, DE>(deserializer: D) -> Result<Option<Url>, DE>
680where
681    D: Deserializer<'de>,
682    DE: serde::de::Error,
683{
684    let raw_url: Option<String> = Option::deserialize(deserializer).map_err(DE::custom)?;
685    raw_url
686        .as_ref()
687        .map(parse_ssh_url)
688        .map(|res| res.map_err(DE::custom))
689        .transpose()
690        .or(Ok(None))
691}
692
693fn requested_reviewers_ignore_null<'de, D, DE>(
694    deserializer: D,
695) -> Result<Option<Vec<structs::User>>, DE>
696where
697    D: Deserializer<'de>,
698    DE: serde::de::Error,
699{
700    let list: Option<Vec<Option<structs::User>>> =
701        Option::deserialize(deserializer).map_err(DE::custom)?;
702    Ok(list.map(|list| list.into_iter().flatten().collect::<Vec<_>>()))
703}
704
705fn parse_ssh_url(raw_url: &String) -> Result<Url, url::ParseError> {
706    // in case of a non-standard ssh-port (not 22), the ssh url coming from the forgejo API
707    // is actually parseable by the url crate, so try to do that first
708    Url::parse(raw_url).or_else(|_| {
709        // otherwise the ssh url is not parseable by the url crate and we try again after some
710        // pre-processing
711        let url = format!("ssh://{url}", url = raw_url.replace(":", "/"));
712        Url::parse(url.as_str())
713    })
714}
715
716#[test]
717fn ssh_url_deserialization() {
718    #[derive(serde::Deserialize)]
719    struct SshUrl {
720        #[serde(deserialize_with = "deserialize_ssh_url")]
721        url: url::Url,
722    }
723    let full_url = r#"{ "url": "ssh://git@codeberg.org/Cyborus/forgejo-api" }"#;
724    let ssh_url = r#"{ "url": "git@codeberg.org:Cyborus/forgejo-api" }"#;
725
726    let full_url_de =
727        serde_json::from_str::<SshUrl>(full_url).expect("failed to deserialize full url");
728    let ssh_url_de =
729        serde_json::from_str::<SshUrl>(ssh_url).expect("failed to deserialize ssh url");
730
731    let expected = "ssh://git@codeberg.org/Cyborus/forgejo-api";
732    assert_eq!(full_url_de.url.as_str(), expected);
733    assert_eq!(ssh_url_de.url.as_str(), expected);
734
735    #[derive(serde::Deserialize)]
736    struct OptSshUrl {
737        #[serde(deserialize_with = "deserialize_optional_ssh_url")]
738        url: Option<url::Url>,
739    }
740    let null_url = r#"{ "url": null }"#;
741
742    let full_url_de = serde_json::from_str::<OptSshUrl>(full_url)
743        .expect("failed to deserialize optional full url");
744    let ssh_url_de =
745        serde_json::from_str::<OptSshUrl>(ssh_url).expect("failed to deserialize optional ssh url");
746    let null_url_de =
747        serde_json::from_str::<OptSshUrl>(null_url).expect("failed to deserialize null url");
748
749    let expected = Some("ssh://git@codeberg.org/Cyborus/forgejo-api");
750    assert_eq!(full_url_de.url.as_ref().map(|u| u.as_ref()), expected);
751    assert_eq!(ssh_url_de.url.as_ref().map(|u| u.as_ref()), expected);
752    assert!(null_url_de.url.is_none());
753}
754
755impl From<structs::DefaultMergeStyle> for structs::MergePullRequestOptionDo {
756    fn from(value: structs::DefaultMergeStyle) -> Self {
757        match value {
758            structs::DefaultMergeStyle::Merge => structs::MergePullRequestOptionDo::Merge,
759            structs::DefaultMergeStyle::Rebase => structs::MergePullRequestOptionDo::Rebase,
760            structs::DefaultMergeStyle::RebaseMerge => {
761                structs::MergePullRequestOptionDo::RebaseMerge
762            }
763            structs::DefaultMergeStyle::Squash => structs::MergePullRequestOptionDo::Squash,
764            structs::DefaultMergeStyle::FastForwardOnly => {
765                structs::MergePullRequestOptionDo::FastForwardOnly
766            }
767        }
768    }
769}
770
771mod sealed {
772    pub trait Sealed {}
773}
774
775pub trait Endpoint: sealed::Sealed {
776    type Response: FromResponse;
777    fn make_request(self) -> RawRequest;
778    fn handle_error(response: ApiResponse) -> Result<(ApiResponse, bool), ForgejoError>;
779}
780
781#[derive(Clone)]
782pub struct RawRequest {
783    method: reqwest::Method,
784    path: Cow<'static, str>,
785    query: Option<Vec<(&'static str, String)>>,
786    body: RequestBody,
787    page: Option<u32>,
788    limit: Option<u32>,
789}
790
791impl RawRequest {
792    pub(crate) fn wrap<E: Endpoint<Response = R>, R>(self, client: &Forgejo) -> Request<'_, E, R> {
793        Request {
794            inner: TypedRequest {
795                inner: self,
796                __endpoint: PhantomData,
797                __response: PhantomData,
798            },
799            client,
800        }
801    }
802
803    #[cfg(feature = "sync")]
804    pub(crate) fn wrap_sync<E: Endpoint<Response = R>, R>(
805        self,
806        client: &sync::Forgejo,
807    ) -> sync::Request<'_, E, R> {
808        sync::Request {
809            inner: TypedRequest {
810                inner: self,
811                __endpoint: PhantomData,
812                __response: PhantomData,
813            },
814            client,
815        }
816    }
817}
818
819pub trait FromResponse {
820    fn from_response(response: ApiResponse, has_body: bool) -> Result<Self, StructureError>
821    where
822        Self: Sized;
823}
824
825#[macro_export]
826macro_rules! impl_from_response {
827    ($t:ty) => {
828        impl $crate::FromResponse for $t {
829            $crate::json_impl!();
830        }
831    };
832}
833#[macro_export]
834#[doc(hidden)]
835macro_rules! json_impl {
836    () => {
837        fn from_response(
838            response: $crate::ApiResponse,
839            has_body: bool,
840        ) -> Result<Self, $crate::StructureError> {
841            soft_assert::soft_assert!(has_body, Err($crate::StructureError::EmptyResponse));
842            serde_json::from_slice(&response.body()).map_err(|e| $crate::StructureError::Serde {
843                e,
844                contents: response.body().clone(),
845            })
846        }
847    };
848}
849
850impl FromResponse for String {
851    fn from_response(
852        response: crate::ApiResponse,
853        has_body: bool,
854    ) -> Result<Self, crate::StructureError> {
855        soft_assert::soft_assert!(has_body, Err(crate::StructureError::EmptyResponse));
856        Ok(std::str::from_utf8(&response.body)?.to_owned())
857    }
858}
859
860impl FromResponse for bytes::Bytes {
861    fn from_response(
862        response: crate::ApiResponse,
863        has_body: bool,
864    ) -> Result<Self, crate::StructureError> {
865        soft_assert::soft_assert!(has_body, Err(crate::StructureError::EmptyResponse));
866        Ok(response.body.clone())
867    }
868}
869
870impl<T: FromResponse + serde::de::DeserializeOwned> FromResponse for Vec<T> {
871    json_impl!();
872}
873
874impl<K, V> FromResponse for BTreeMap<K, V>
875where
876    BTreeMap<K, V>: serde::de::DeserializeOwned,
877{
878    json_impl!();
879}
880
881impl FromResponse for Vec<u8> {
882    fn from_response(
883        response: crate::ApiResponse,
884        has_body: bool,
885    ) -> Result<Self, crate::StructureError> {
886        soft_assert::soft_assert!(has_body, Err(crate::StructureError::EmptyResponse));
887        Ok(response.body.to_vec())
888    }
889}
890
891impl<
892        T: FromResponse,
893        H: for<'a> TryFrom<&'a reqwest::header::HeaderMap, Error = crate::StructureError>,
894    > FromResponse for (H, T)
895{
896    fn from_response(
897        response: crate::ApiResponse,
898        has_body: bool,
899    ) -> Result<Self, crate::StructureError> {
900        let headers = H::try_from(&response.headers)?;
901        let body = T::from_response(response, has_body)?;
902        Ok((headers, body))
903    }
904}
905
906impl<T: FromResponse> FromResponse for Option<T> {
907    fn from_response(
908        response: crate::ApiResponse,
909        has_body: bool,
910    ) -> Result<Self, crate::StructureError> {
911        if has_body {
912            T::from_response(response, true).map(Some)
913        } else {
914            Ok(None)
915        }
916    }
917}
918
919impl_from_response!(bool);
920
921impl FromResponse for () {
922    fn from_response(_: crate::ApiResponse, _: bool) -> Result<Self, crate::StructureError> {
923        Ok(())
924    }
925}
926
927#[derive(Clone)]
928pub enum RequestBody {
929    Json(bytes::Bytes),
930    Form(Vec<(&'static str, Vec<u8>)>),
931    None,
932}
933
934pub struct TypedRequest<E, R> {
935    inner: RawRequest,
936    __endpoint: PhantomData<*const E>,
937    __response: PhantomData<*const R>,
938}
939
940impl<E: Endpoint, R: FromResponse> TypedRequest<E, R> {
941    async fn send(&self, client: &Forgejo) -> Result<R, ForgejoError> {
942        let (response, has_body) = E::handle_error(client.send_request(&self.inner).await?)?;
943        Ok(R::from_response(response, has_body)?)
944    }
945
946    #[cfg(feature = "sync")]
947    fn send_sync(&self, client: &sync::Forgejo) -> Result<R, ForgejoError> {
948        let (response, has_body) = E::handle_error(client.send_request(&self.inner)?)?;
949        Ok(R::from_response(response, has_body)?)
950    }
951}
952
953pub struct ApiResponse {
954    status_code: StatusCode,
955    headers: reqwest::header::HeaderMap,
956    body: bytes::Bytes,
957}
958
959impl ApiResponse {
960    pub fn status_code(&self) -> StatusCode {
961        self.status_code
962    }
963
964    pub fn headers(&self) -> &reqwest::header::HeaderMap {
965        &self.headers
966    }
967
968    pub fn body(&self) -> &bytes::Bytes {
969        &self.body
970    }
971}
972
973pub struct Request<'a, E, R> {
974    inner: TypedRequest<E, R>,
975    client: &'a Forgejo,
976}
977
978impl<'a, E: Endpoint, R: FromResponse> Request<'a, E, R> {
979    pub async fn send(self) -> Result<R, ForgejoError> {
980        self.inner.send(self.client).await
981    }
982
983    pub fn response_type<T: FromResponse>(self) -> Request<'a, E, T> {
984        Request {
985            inner: TypedRequest {
986                inner: self.inner.inner,
987                __endpoint: PhantomData,
988                __response: PhantomData,
989            },
990            client: self.client,
991        }
992    }
993
994    pub fn page(mut self, page: u32) -> Self {
995        self.inner.inner.page = Some(page);
996        self
997    }
998
999    pub fn page_size(mut self, limit: u32) -> Self {
1000        self.inner.inner.limit = Some(limit);
1001        self
1002    }
1003}
1004
1005pub trait CountHeader: sealed::Sealed {
1006    fn count(&self) -> Option<usize>;
1007}
1008
1009pub trait PageSize: sealed::Sealed {
1010    fn page_size(&self) -> usize;
1011}
1012
1013impl<T> sealed::Sealed for Vec<T> {}
1014impl<T> PageSize for Vec<T> {
1015    fn page_size(&self) -> usize {
1016        self.len()
1017    }
1018}
1019
1020impl<'a, E: Endpoint, H: CountHeader, T: PageSize> Request<'a, E, (H, T)>
1021where
1022    (H, T): FromResponse,
1023{
1024    pub fn stream_pages(self) -> PageStream<'a, E, T, H> {
1025        PageStream {
1026            request: self,
1027            total_seen: 0,
1028            finished: false,
1029            fut: None,
1030        }
1031    }
1032}
1033
1034pub struct PageStream<'a, E: Endpoint, T, H> {
1035    request: Request<'a, E, (H, T)>,
1036    total_seen: usize,
1037    finished: bool,
1038    fut: Option<Pin<Box<dyn Future<Output = Result<(H, T), ForgejoError>> + 'a>>>,
1039}
1040
1041impl<'a, E: Endpoint, T: PageSize, H: CountHeader> futures::stream::Stream
1042    for PageStream<'a, E, T, H>
1043where
1044    Self: Unpin + 'a,
1045    (H, T): FromResponse,
1046{
1047    type Item = Result<T, ForgejoError>;
1048
1049    fn poll_next(
1050        mut self: Pin<&mut Self>,
1051        cx: &mut std::task::Context<'_>,
1052    ) -> Poll<Option<Self::Item>> {
1053        if self.finished {
1054            return Poll::Ready(None);
1055        }
1056        match &mut self.fut {
1057            None => {
1058                let request = self.request.inner.inner.clone();
1059                let client = self.request.client;
1060                let fut = Box::pin(async move {
1061                    E::handle_error(client.send_request(&request).await?).and_then(|(res, body)| {
1062                        <(H, T)>::from_response(res, body).map_err(|e| e.into())
1063                    })
1064                });
1065                self.fut = Some(fut);
1066                cx.waker().wake_by_ref();
1067                Poll::Pending
1068            }
1069            Some(fut) => {
1070                let (headers, page_content) = match fut.as_mut().poll(cx) {
1071                    Poll::Ready(Ok(response)) => response,
1072                    Poll::Ready(Err(e)) => {
1073                        self.finished = true;
1074                        return Poll::Ready(Some(Err(e)));
1075                    }
1076                    Poll::Pending => return Poll::Pending,
1077                };
1078                self.total_seen += page_content.page_size();
1079                let total_count = match headers.count() {
1080                    Some(n) => n,
1081                    None => {
1082                        self.finished = true;
1083                        return Poll::Ready(Some(Err(StructureError::HeaderMissing(
1084                            "x-total-count",
1085                        )
1086                        .into())));
1087                    }
1088                };
1089
1090                if self.total_seen >= total_count {
1091                    self.finished = true;
1092                } else {
1093                    self.request.inner.inner.page =
1094                        Some(self.request.inner.inner.page.unwrap_or(1) + 1);
1095                    self.fut = None;
1096                }
1097
1098                Poll::Ready(Some(Ok(page_content)))
1099            }
1100        }
1101    }
1102}
1103
1104impl<'a, E: Endpoint + 'a, T: 'a, H: CountHeader + 'a> Request<'a, E, (H, Vec<T>)>
1105where
1106    (H, Vec<T>): FromResponse,
1107{
1108    pub fn stream(self) -> impl futures::Stream<Item = Result<T, ForgejoError>> + use<'a, E, T, H> {
1109        use futures::TryStreamExt;
1110        self.stream_pages()
1111            .map_ok(|page| futures::stream::iter(page.into_iter().map(Ok)))
1112            .try_flatten()
1113    }
1114
1115    pub async fn all(self) -> Result<Vec<T>, ForgejoError> {
1116        use futures::TryStreamExt;
1117
1118        self.stream().try_collect().await
1119    }
1120}
1121
1122impl<'a, E: Endpoint, R: FromResponse> std::future::IntoFuture for Request<'a, E, R> {
1123    type Output = Result<R, ForgejoError>;
1124
1125    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send + Sync + 'a>>;
1126
1127    fn into_future(self) -> Self::IntoFuture {
1128        Box::pin(async move {
1129            let (response, has_body) =
1130                E::handle_error(self.client.send_request(&self.inner.inner).await?)?;
1131            Ok(R::from_response(response, has_body)?)
1132        })
1133    }
1134}