gitlab/
gitlab.rs

1// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
2// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
3// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
4// option. This file may not be copied, modified, or distributed
5// except according to those terms.
6
7use std::any;
8use std::convert::TryInto;
9use std::fmt::{self, Debug};
10
11use async_trait::async_trait;
12use bytes::Bytes;
13use graphql_client::{GraphQLQuery, QueryBody, Response};
14use http::{HeaderMap, Response as HttpResponse};
15use itertools::Itertools;
16use log::{debug, error, info};
17use reqwest::blocking::Client;
18use reqwest::Client as AsyncClient;
19use serde::de::DeserializeOwned;
20use serde::Deserialize;
21use thiserror::Error;
22use url::Url;
23
24#[cfg(any(feature = "client_der", feature = "client_pem"))]
25use reqwest::Identity as TlsIdentity;
26
27use crate::api;
28use crate::auth::{Auth, AuthError};
29
30#[derive(Debug, Error)]
31#[non_exhaustive]
32pub enum GitlabError {
33    #[error("failed to parse url: {}", source)]
34    UrlParse {
35        #[from]
36        source: url::ParseError,
37    },
38    #[error("error setting auth header: {}", source)]
39    AuthError {
40        #[from]
41        source: AuthError,
42    },
43    #[error("communication with gitlab: {}", source)]
44    Communication {
45        #[from]
46        source: reqwest::Error,
47    },
48    #[error("gitlab HTTP error: {}", status)]
49    Http { status: reqwest::StatusCode },
50    #[allow(clippy::upper_case_acronyms)]
51    #[error("graphql error: [\"{}\"]", message.iter().format("\", \""))]
52    GraphQL { message: Vec<graphql_client::Error> },
53    #[error("no response from gitlab")]
54    NoResponse {},
55    #[error("could not parse {} data from JSON: {}", typename, source)]
56    DataType {
57        #[source]
58        source: serde_json::Error,
59        typename: &'static str,
60    },
61    #[error("api error: {}", source)]
62    Api {
63        #[from]
64        source: api::ApiError<RestError>,
65    },
66}
67
68impl GitlabError {
69    fn http(status: reqwest::StatusCode) -> Self {
70        GitlabError::Http {
71            status,
72        }
73    }
74
75    fn graphql(message: Vec<graphql_client::Error>) -> Self {
76        GitlabError::GraphQL {
77            message,
78        }
79    }
80
81    fn no_response() -> Self {
82        GitlabError::NoResponse {}
83    }
84
85    fn data_type<T>(source: serde_json::Error) -> Self {
86        GitlabError::DataType {
87            source,
88            typename: any::type_name::<T>(),
89        }
90    }
91}
92
93type GitlabResult<T> = Result<T, GitlabError>;
94
95// Private enum that enables the parsing of the cert bytes to be
96// delayed until the client is built rather than when they're passed
97// to a builder.
98#[derive(Clone)]
99enum ClientCert {
100    None,
101    #[cfg(feature = "client_der")]
102    Der(Vec<u8>, String),
103    #[cfg(feature = "client_pem")]
104    Pem(Vec<u8>),
105}
106
107/// A representation of the Gitlab API for a single user.
108///
109/// Separate users should use separate instances of this.
110#[derive(Clone)]
111pub struct Gitlab {
112    /// The client to use for API calls.
113    client: Client,
114    /// The base URL to use for API calls.
115    rest_url: Url,
116    /// The URL to use for GraphQL API calls.
117    graphql_url: Url,
118    /// The authentication information to use when communicating with Gitlab.
119    auth: Auth,
120}
121
122impl Debug for Gitlab {
123    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
124        f.debug_struct("Gitlab")
125            .field("rest_url", &self.rest_url)
126            .field("graphql_url", &self.graphql_url)
127            .finish()
128    }
129}
130
131/// Should a certificate be validated in tls connections.
132/// The Insecure option is used for self-signed certificates.
133#[derive(Debug, Clone)]
134enum CertPolicy {
135    Default,
136    Insecure,
137}
138
139impl Gitlab {
140    /// Create a new Gitlab API representation.
141    ///
142    /// The `token` should be a valid [personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html).
143    /// Errors out if `token` is invalid.
144    pub fn new<H, T>(host: H, token: T) -> GitlabResult<Self>
145    where
146        H: AsRef<str>,
147        T: Into<String>,
148    {
149        Self::new_impl(
150            "https",
151            host.as_ref(),
152            Auth::Token(token.into()),
153            CertPolicy::Default,
154            ClientCert::None,
155        )
156    }
157
158    /// Create a new non-SSL Gitlab API representation.
159    ///
160    /// Errors out if `token` is invalid.
161    pub fn new_insecure<H, T>(host: H, token: T) -> GitlabResult<Self>
162    where
163        H: AsRef<str>,
164        T: Into<String>,
165    {
166        Self::new_impl(
167            "http",
168            host.as_ref(),
169            Auth::Token(token.into()),
170            CertPolicy::Insecure,
171            ClientCert::None,
172        )
173    }
174
175    /// Create a new Gitlab API representation.
176    ///
177    /// The `token` should be a valid [job token](https://docs.gitlab.com/ee/ci/jobs/ci_job_token.html).
178    /// Errors out if `token` is invalid.
179    pub fn new_job_token<H, T>(host: H, token: T) -> GitlabResult<Self>
180    where
181        H: AsRef<str>,
182        T: Into<String>,
183    {
184        Self::new_impl(
185            "https",
186            host.as_ref(),
187            Auth::JobToken(token.into()),
188            CertPolicy::Default,
189            ClientCert::None,
190        )
191    }
192
193    /// Create a new non-SSL Gitlab API representation.
194    ///
195    /// The `token` should be a valid [job token](https://docs.gitlab.com/ee/ci/jobs/ci_job_token.html).
196    /// Errors out if `token` is invalid.
197    pub fn new_job_token_insecure<H, T>(host: H, token: T) -> GitlabResult<Self>
198    where
199        H: AsRef<str>,
200        T: Into<String>,
201    {
202        Self::new_impl(
203            "http",
204            host.as_ref(),
205            Auth::JobToken(token.into()),
206            CertPolicy::Insecure,
207            ClientCert::None,
208        )
209    }
210
211    /// Create a new Gitlab API representation.
212    ///
213    /// The `token` should be a valid [OAuth2 token](https://docs.gitlab.com/ee/api/oauth2.html).
214    /// Errors out if `token` is invalid.
215    pub fn with_oauth2<H, T>(host: H, token: T) -> GitlabResult<Self>
216    where
217        H: AsRef<str>,
218        T: Into<String>,
219    {
220        Self::new_impl(
221            "https",
222            host.as_ref(),
223            Auth::OAuth2(token.into()),
224            CertPolicy::Default,
225            ClientCert::None,
226        )
227    }
228
229    /// Create a new non-SSL Gitlab API representation.
230    ///
231    /// The `token` should be a valid [OAuth2 token](https://docs.gitlab.com/ee/api/oauth2.html).
232    /// Errors out if `token` is invalid.
233    pub fn with_oauth2_insecure<H, T>(host: H, token: T) -> GitlabResult<Self>
234    where
235        H: AsRef<str>,
236        T: Into<String>,
237    {
238        Self::new_impl(
239            "http",
240            host.as_ref(),
241            Auth::OAuth2(token.into()),
242            CertPolicy::Default,
243            ClientCert::None,
244        )
245    }
246
247    /// Internal method to create a new Gitlab client.
248    fn new_impl(
249        protocol: &str,
250        host: &str,
251        auth: Auth,
252        cert_validation: CertPolicy,
253        identity: ClientCert,
254    ) -> GitlabResult<Self> {
255        let rest_url = Url::parse(&format!("{}://{}/api/v4/", protocol, host))?;
256        let graphql_url = Url::parse(&format!("{}://{}/api/graphql", protocol, host))?;
257
258        let client = match cert_validation {
259            CertPolicy::Insecure => {
260                Client::builder()
261                    .danger_accept_invalid_certs(true)
262                    .build()?
263            },
264            CertPolicy::Default => {
265                match identity {
266                    ClientCert::None => Client::new(),
267                    #[cfg(feature = "client_der")]
268                    ClientCert::Der(der, password) => {
269                        let id = TlsIdentity::from_pkcs12_der(&der, &password)?;
270                        Client::builder().identity(id).build()?
271                    },
272                    #[cfg(feature = "client_pem")]
273                    ClientCert::Pem(pem) => {
274                        let id = TlsIdentity::from_pem(&pem)?;
275                        Client::builder().identity(id).build()?
276                    },
277                }
278            },
279        };
280
281        let api = Gitlab {
282            client,
283            rest_url,
284            graphql_url,
285            auth,
286        };
287
288        // Ensure the API is working.
289        api.auth.check_connection(&api)?;
290
291        Ok(api)
292    }
293
294    /// Create a new Gitlab API client builder.
295    pub fn builder<H, T>(host: H, token: T) -> GitlabBuilder
296    where
297        H: Into<String>,
298        T: Into<String>,
299    {
300        GitlabBuilder::new(host, token)
301    }
302
303    /// Send a GraphQL query.
304    pub fn graphql<Q>(&self, query: &QueryBody<Q::Variables>) -> GitlabResult<Q::ResponseData>
305    where
306        Q: GraphQLQuery,
307        Q::Variables: Debug,
308        for<'d> Q::ResponseData: Deserialize<'d>,
309    {
310        info!(
311            target: "gitlab",
312            "sending GraphQL query '{}' {:?}",
313            query.operation_name,
314            query.variables,
315        );
316        let req = self.client.post(self.graphql_url.clone()).json(query);
317        let rsp: Response<Q::ResponseData> = self.send(req)?;
318
319        if let Some(errs) = rsp.errors {
320            return Err(GitlabError::graphql(errs));
321        }
322        rsp.data.ok_or_else(GitlabError::no_response)
323    }
324
325    /// Refactored code which talks to Gitlab and transforms error messages properly.
326    fn send<T>(&self, req: reqwest::blocking::RequestBuilder) -> GitlabResult<T>
327    where
328        T: DeserializeOwned,
329    {
330        let auth_headers = {
331            let mut headers = HeaderMap::default();
332            self.auth.set_header(&mut headers)?;
333            headers
334        };
335        let rsp = req.headers(auth_headers).send()?;
336        let status = rsp.status();
337        if status.is_server_error() {
338            return Err(GitlabError::http(status));
339        }
340
341        serde_json::from_reader::<_, T>(rsp).map_err(GitlabError::data_type::<T>)
342    }
343
344    /// Perform a REST query with a given auth.
345    fn rest_auth(
346        &self,
347        mut request: http::request::Builder,
348        body: Vec<u8>,
349        auth: &Auth,
350    ) -> Result<HttpResponse<Bytes>, api::ApiError<<Self as api::RestClient>::Error>> {
351        let call = || -> Result<_, RestError> {
352            auth.set_header(request.headers_mut().unwrap())?;
353            let http_request = request.body(body)?;
354            let request = http_request.try_into()?;
355            let rsp = self.client.execute(request)?;
356
357            let mut http_rsp = HttpResponse::builder()
358                .status(rsp.status())
359                .version(rsp.version());
360            let headers = http_rsp.headers_mut().unwrap();
361            for (key, value) in rsp.headers() {
362                headers.insert(key, value.clone());
363            }
364            Ok(http_rsp.body(rsp.bytes()?)?)
365        };
366        call().map_err(api::ApiError::client)
367    }
368}
369
370#[derive(Debug, Error)]
371#[non_exhaustive]
372pub enum RestError {
373    #[error("error setting auth header: {}", source)]
374    AuthError {
375        #[from]
376        source: AuthError,
377    },
378    #[error("communication with gitlab: {}", source)]
379    Communication {
380        #[from]
381        source: reqwest::Error,
382    },
383    #[error("`http` error: {}", source)]
384    Http {
385        #[from]
386        source: http::Error,
387    },
388}
389
390impl api::RestClient for Gitlab {
391    type Error = RestError;
392
393    fn rest_endpoint(&self, endpoint: &str) -> Result<Url, api::ApiError<Self::Error>> {
394        debug!(target: "gitlab", "REST api call {}", endpoint);
395        Ok(self.rest_url.join(endpoint)?)
396    }
397}
398
399impl api::Client for Gitlab {
400    fn rest(
401        &self,
402        request: http::request::Builder,
403        body: Vec<u8>,
404    ) -> Result<HttpResponse<Bytes>, api::ApiError<Self::Error>> {
405        self.rest_auth(request, body, &self.auth)
406    }
407}
408
409pub struct GitlabBuilder {
410    protocol: &'static str,
411    host: String,
412    token: Auth,
413    cert_validation: CertPolicy,
414    identity: ClientCert,
415}
416
417impl GitlabBuilder {
418    /// Create a new Gitlab API client builder.
419    pub fn new<H, T>(host: H, token: T) -> Self
420    where
421        H: Into<String>,
422        T: Into<String>,
423    {
424        Self {
425            protocol: "https",
426            host: host.into(),
427            token: Auth::Token(token.into()),
428            cert_validation: CertPolicy::Default,
429            identity: ClientCert::None,
430        }
431    }
432
433    /// Create a new unauthenticated Gitlab API client builder.
434    pub fn new_unauthenticated<H>(host: H) -> Self
435    where
436        H: Into<String>,
437    {
438        Self {
439            protocol: "https",
440            host: host.into(),
441            token: Auth::None,
442            cert_validation: CertPolicy::Default,
443            identity: ClientCert::None,
444        }
445    }
446
447    /// Create a new Gitlab API client builder with job token.
448    pub fn new_with_job_token<H, T>(host: H, token: T) -> Self
449    where
450        H: Into<String>,
451        T: Into<String>,
452    {
453        Self {
454            protocol: "https",
455            host: host.into(),
456            token: Auth::JobToken(token.into()),
457            cert_validation: CertPolicy::Default,
458            identity: ClientCert::None,
459        }
460    }
461
462    /// Switch to an insecure protocol (http instead of https).
463    pub fn insecure(&mut self) -> &mut Self {
464        self.protocol = "http";
465        self
466    }
467
468    pub fn cert_insecure(&mut self) -> &mut Self {
469        self.cert_validation = CertPolicy::Insecure;
470        self
471    }
472
473    /// Switch to using an OAuth2 token instead of a personal access token
474    pub fn oauth2_token(&mut self) -> &mut Self {
475        if let Auth::Token(token) = self.token.clone() {
476            self.token = Auth::OAuth2(token);
477        }
478        self
479    }
480
481    /// Switch to using an job token instead of a personal access token
482    pub fn job_token(&mut self) -> &mut Self {
483        if let Auth::Token(token) = self.token.clone() {
484            self.token = Auth::JobToken(token);
485        }
486        self
487    }
488
489    /// [Authenticate to Gitlab](reqwest::Identity) with the provided
490    /// DER-formatted PKCS#12 archive.
491    #[cfg(any(doc, feature = "client_der"))]
492    pub fn client_identity_from_der(&mut self, der: &[u8], password: &str) -> &mut Self {
493        self.identity = ClientCert::Der(der.into(), password.into());
494        self
495    }
496
497    /// [Authenticate to Gitlab](reqwest::Identity) with the provided
498    /// PEM-encoded private key and certificate.
499    #[cfg(any(doc, feature = "client_pem"))]
500    pub fn client_identity_from_pem(&mut self, pem: &[u8]) -> &mut Self {
501        self.identity = ClientCert::Pem(pem.into());
502        self
503    }
504
505    pub fn build(&self) -> GitlabResult<Gitlab> {
506        Gitlab::new_impl(
507            self.protocol,
508            &self.host,
509            self.token.clone(),
510            self.cert_validation.clone(),
511            self.identity.clone(),
512        )
513    }
514
515    pub async fn build_async(&self) -> GitlabResult<AsyncGitlab> {
516        AsyncGitlab::new_impl(
517            self.protocol,
518            &self.host,
519            self.token.clone(),
520            self.cert_validation.clone(),
521            self.identity.clone(),
522        )
523        .await
524    }
525}
526
527/// A representation of the asynchronous Gitlab API for a single user.
528///
529/// Separate users should use separate instances of this.
530#[derive(Clone)]
531pub struct AsyncGitlab {
532    /// The client to use for API calls.
533    client: reqwest::Client,
534    /// The base URL to use for API calls.
535    instance_url: Url,
536    /// The base URL to use for REST API calls.
537    rest_url: Url,
538    /// The URL to use for GraphQL API calls.
539    graphql_url: Url,
540    /// The authentication information to use when communicating with Gitlab.
541    auth: Auth,
542}
543
544impl Debug for AsyncGitlab {
545    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
546        f.debug_struct("AsyncGitlab")
547            .field("instance_url", &self.instance_url)
548            .field("rest_url", &self.rest_url)
549            .field("graphql_url", &self.graphql_url)
550            .finish()
551    }
552}
553
554#[async_trait]
555impl api::RestClient for AsyncGitlab {
556    type Error = RestError;
557
558    fn rest_endpoint(&self, endpoint: &str) -> Result<Url, api::ApiError<Self::Error>> {
559        debug!(target: "gitlab", "REST api call {}", endpoint);
560        Ok(self.rest_url.join(endpoint)?)
561    }
562
563    fn instance_endpoint(&self, endpoint: &str) -> Result<Url, api::ApiError<Self::Error>> {
564        debug!(target: "gitlab", "instance api call {}", endpoint);
565        Ok(self.instance_url.join(endpoint)?)
566    }
567}
568
569#[async_trait]
570impl api::AsyncClient for AsyncGitlab {
571    async fn rest_async(
572        &self,
573        request: http::request::Builder,
574        body: Vec<u8>,
575    ) -> Result<HttpResponse<Bytes>, api::ApiError<<Self as api::RestClient>::Error>> {
576        self.rest_async_auth(request, body, &self.auth).await
577    }
578}
579
580impl AsyncGitlab {
581    /// Internal method to create a new Gitlab client.
582    async fn new_impl(
583        protocol: &str,
584        host: &str,
585        auth: Auth,
586        cert_validation: CertPolicy,
587        identity: ClientCert,
588    ) -> GitlabResult<Self> {
589        let instance_url = Url::parse(&format!("{}://{}/", protocol, host))?;
590        let rest_url = Url::parse(&format!("{}://{}/api/v4/", protocol, host))?;
591        let graphql_url = Url::parse(&format!("{}://{}/api/graphql", protocol, host))?;
592
593        let client = match cert_validation {
594            CertPolicy::Insecure => {
595                AsyncClient::builder()
596                    .danger_accept_invalid_certs(true)
597                    .build()?
598            },
599            CertPolicy::Default => {
600                match identity {
601                    ClientCert::None => AsyncClient::new(),
602                    #[cfg(feature = "client_der")]
603                    ClientCert::Der(der, password) => {
604                        let id = TlsIdentity::from_pkcs12_der(&der, &password)?;
605                        AsyncClient::builder().identity(id).build()?
606                    },
607                    #[cfg(feature = "client_pem")]
608                    ClientCert::Pem(pem) => {
609                        let id = TlsIdentity::from_pem(&pem)?;
610                        AsyncClient::builder().identity(id).build()?
611                    },
612                }
613            },
614        };
615
616        let api = AsyncGitlab {
617            client,
618            instance_url,
619            rest_url,
620            graphql_url,
621            auth,
622        };
623
624        // Ensure the API is working.
625        api.auth.check_connection_async(&api).await?;
626
627        Ok(api)
628    }
629
630    /// Send a GraphQL query.
631    pub async fn graphql<Q>(&self, query: &QueryBody<Q::Variables>) -> GitlabResult<Q::ResponseData>
632    where
633        Q: GraphQLQuery,
634        Q::Variables: Debug,
635        for<'d> Q::ResponseData: Deserialize<'d>,
636    {
637        info!(
638            target: "gitlab",
639            "sending GraphQL query '{}' {:?}",
640            query.operation_name,
641            query.variables,
642        );
643        let req = self.client.post(self.graphql_url.clone()).json(query);
644        let rsp: Response<Q::ResponseData> = self.send(req).await?;
645
646        if let Some(errs) = rsp.errors {
647            return Err(GitlabError::graphql(errs));
648        }
649        rsp.data.ok_or_else(GitlabError::no_response)
650    }
651
652    /// Refactored code which talks to Gitlab and transforms error messages properly.
653    async fn send<T>(&self, req: reqwest::RequestBuilder) -> GitlabResult<T>
654    where
655        T: DeserializeOwned,
656    {
657        let auth_headers = {
658            let mut headers = HeaderMap::default();
659            self.auth.set_header(&mut headers)?;
660            headers
661        };
662        let rsp = req.headers(auth_headers).send().await?;
663        let status = rsp.status();
664        if status.is_server_error() {
665            return Err(GitlabError::http(status));
666        }
667
668        serde_json::from_slice::<T>(&rsp.bytes().await?).map_err(GitlabError::data_type::<T>)
669    }
670
671    /// Perform a REST query with a given auth.
672    async fn rest_async_auth(
673        &self,
674        mut request: http::request::Builder,
675        body: Vec<u8>,
676        auth: &Auth,
677    ) -> Result<HttpResponse<Bytes>, api::ApiError<<Self as api::RestClient>::Error>> {
678        use futures_util::TryFutureExt;
679        let call = || {
680            async {
681                auth.set_header(request.headers_mut().unwrap())?;
682                let http_request = request.body(body)?;
683                let request = http_request.try_into()?;
684                let rsp = self.client.execute(request).await?;
685
686                let mut http_rsp = HttpResponse::builder()
687                    .status(rsp.status())
688                    .version(rsp.version());
689                let headers = http_rsp.headers_mut().unwrap();
690                for (key, value) in rsp.headers() {
691                    headers.insert(key, value.clone());
692                }
693                Ok(http_rsp.body(rsp.bytes().await?)?)
694            }
695        };
696        call().map_err(api::ApiError::client).await
697    }
698}
699
700#[derive(Clone)]
701pub struct ImpersonationClient<'a, T> {
702    auth: Auth,
703    client: &'a T,
704}
705
706impl<'a, C> ImpersonationClient<'a, C> {
707    /// Wrap an existing client using an impersonation token.
708    pub fn new<T>(client: &'a C, token: T) -> Self
709    where
710        T: Into<String>,
711    {
712        Self {
713            auth: Auth::Token(token.into()),
714            client,
715        }
716    }
717
718    /// Switch to using an OAuth2 token instead of a personal access token
719    pub fn oauth2_token(&mut self) -> &mut Self {
720        if let Auth::Token(auth) = self.auth.clone() {
721            self.auth = Auth::OAuth2(auth);
722        }
723        self
724    }
725}
726
727impl<C> api::RestClient for ImpersonationClient<'_, C>
728where
729    C: api::RestClient,
730{
731    type Error = C::Error;
732
733    fn rest_endpoint(&self, endpoint: &str) -> Result<Url, api::ApiError<Self::Error>> {
734        self.client.rest_endpoint(endpoint)
735    }
736
737    fn instance_endpoint(&self, endpoint: &str) -> Result<Url, api::ApiError<Self::Error>> {
738        self.client.instance_endpoint(endpoint)
739    }
740}
741
742impl api::Client for ImpersonationClient<'_, Gitlab> {
743    fn rest(
744        &self,
745        request: http::request::Builder,
746        body: Vec<u8>,
747    ) -> Result<HttpResponse<Bytes>, api::ApiError<Self::Error>> {
748        self.client.rest_auth(request, body, &self.auth)
749    }
750}
751
752#[allow(clippy::needless_lifetimes)] // `async_trait` wants the lifetime named.
753#[async_trait]
754impl<'a> api::AsyncClient for ImpersonationClient<'a, AsyncGitlab> {
755    async fn rest_async(
756        &self,
757        request: http::request::Builder,
758        body: Vec<u8>,
759    ) -> Result<HttpResponse<Bytes>, api::ApiError<<Self as api::RestClient>::Error>> {
760        self.client.rest_async_auth(request, body, &self.auth).await
761    }
762}