gl_env/
gitlab.rs

1#![allow(clippy::result_large_err)]
2
3use std::{io, time::Duration};
4
5use serde::Serialize;
6use url::Url;
7
8use crate::{cli::CommonArgs, APP_UA};
9
10mod models;
11pub use models::*;
12
13pub type FetchResult<T> = std::result::Result<T, FetchError>;
14
15/// Tiny GitLab SDK
16///
17/// Only implements the features we actually use
18pub struct Gitlab {
19    agent: ureq::Agent,
20    url: Url,
21    auth_header: String,
22}
23
24impl Gitlab {
25    pub fn new(url: &Url, token: &str) -> Self {
26        let agent = ureq::AgentBuilder::new()
27            .https_only(true)
28            .redirects(0)
29            .timeout(Duration::from_secs(10))
30            .user_agent(APP_UA)
31            .try_proxy_from_env(true)
32            .build();
33
34        let auth_header = format!("Bearer {token}");
35        let url = url.join("api/v4/").unwrap();
36
37        Self {
38            agent,
39            url,
40            auth_header,
41        }
42    }
43
44    /// List all CI/CD variables for a group or project
45    pub fn list_variables(&self, target: &Target) -> FetchResult<Vec<Variable>> {
46        let url = target.url_for_list(&self.url)?;
47        self.get(&url)?.into_json().map_err(FetchError::from)
48    }
49
50    /// Create a new variable.
51    ///
52    /// If a variable with the same key already exists, the new variable must have a different
53    /// `environment_scope`. Otherwise, GitLab returns a message similar to: `VARIABLE_NAME has
54    /// already been taken`.
55    pub fn create_variable(&self, target: &Target, variable: &Variable) -> FetchResult<Variable> {
56        let url = target.url_for_list(&self.url)?;
57        self.post(&url, variable)?
58            .into_json()
59            .map_err(FetchError::from)
60    }
61
62    /// Update a variable.
63    pub fn update_variable(&self, target: &Target, variable: &Variable) -> FetchResult<Variable> {
64        let url = target.url_for_item(&self.url, variable)?;
65        dbg!(&url.as_str());
66        self.put(&url, variable)?
67            .into_json()
68            .map_err(FetchError::from)
69    }
70
71    /// Delete a variable
72    pub fn delete_variable(&self, target: &Target, variable: &Variable) -> FetchResult<()> {
73        let url = target.url_for_item(&self.url, variable)?;
74        self.delete(&url)?;
75        Ok(())
76    }
77
78    /// Perform an authenticated GET request
79    fn get(&self, url: &Url) -> FetchResult<ureq::Response> {
80        self.agent
81            .get(url.as_str())
82            .set("Authorization", &self.auth_header)
83            .call()
84            .map_err(FetchError::from)
85    }
86
87    /// Perform an authenticated POST request
88    fn post<T: Serialize>(&self, url: &Url, body: T) -> FetchResult<ureq::Response> {
89        self.agent
90            .post(url.as_str())
91            .set("Authorization", &self.auth_header)
92            .send_json(body)
93            .map_err(FetchError::from)
94    }
95
96    /// Perform an authenticated PUT request
97    fn put<T: Serialize>(&self, url: &Url, body: T) -> FetchResult<ureq::Response> {
98        self.agent
99            .put(url.as_str())
100            .set("Authorization", &self.auth_header)
101            .send_json(body)
102            .map_err(FetchError::from)
103    }
104
105    /// Perform an authenticated DELETE request
106    fn delete(&self, url: &Url) -> FetchResult<ureq::Response> {
107        self.agent
108            .delete(url.as_str())
109            .set("Authorization", &self.auth_header)
110            .call()
111            .map_err(FetchError::from)
112    }
113}
114
115impl From<&CommonArgs> for Gitlab {
116    fn from(args: &CommonArgs) -> Self {
117        Self::new(&args.url, &args.token)
118    }
119}
120
121/// Errors that might occur when performing requests to the GitLab API.
122#[derive(Debug, thiserror::Error)]
123pub enum FetchError {
124    #[error("Invalid URL: {0}")]
125    Url(#[from] url::ParseError),
126
127    #[error("Failed to send request: {0}")]
128    RequestFailed(#[source] ureq::Transport),
129
130    #[error("HTTP {status}: {body}")]
131    HttpStatus { status: u16, body: String },
132
133    #[error("Failed to deserialize Body: {0}")]
134    Json(#[from] io::Error),
135}
136
137impl From<ureq::Error> for FetchError {
138    fn from(value: ureq::Error) -> Self {
139        match value {
140            ureq::Error::Transport(transport) => Self::RequestFailed(transport),
141            ureq::Error::Status(status, res) => {
142                let body = res.into_string().unwrap_or_default();
143                Self::HttpStatus { status, body }
144            }
145        }
146    }
147}
148
149/// Either a Group or a Project
150pub enum Target {
151    Project(String),
152    Group(String),
153}
154
155impl Target {
156    pub fn url_for_list(&self, base: &Url) -> FetchResult<Url> {
157        let (kind, target_id) = match self {
158            Target::Project(p) => ("project", urlencode(p)),
159            Target::Group(g) => ("group", urlencode(g)),
160        };
161
162        base.join(&format!("{kind}s/{target_id}/variables"))
163            .map_err(FetchError::from)
164    }
165
166    pub fn url_for_item(&self, base: &Url, variable: &Variable) -> FetchResult<Url> {
167        let key = variable.key.as_str();
168        let mut url = self.url_for_list(base)?.join(&format!("variables/{key}"))?;
169        url.query_pairs_mut()
170            .append_pair("filter[environment_scope]", &variable.environment_scope);
171        Ok(url)
172    }
173}
174
175impl From<&CommonArgs> for Target {
176    fn from(args: &CommonArgs) -> Self {
177        match (args.group.clone(), args.project.clone()) {
178            (Some(v), None) => return Self::Group(v),
179            (None, Some(v)) => return Self::Project(v),
180            _ => panic!("either -g/--group or -p/--project must be passed"),
181        }
182    }
183}
184
185fn urlencode(s: &str) -> String {
186    const RESERVED_CHARS: &[u8; 18] = b"!#$&'()*+,/:;=?@[]";
187    let mut s = s.to_string();
188
189    for c in RESERVED_CHARS {
190        let t = format!("%{:X}", c);
191        s = s.replace(*c as char, &t);
192    }
193    s
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn urlencode_encodes_str() {
202        assert_eq!(urlencode("foo/bar"), "foo%2Fbar");
203        assert_eq!(
204            urlencode("!#$&'()*+,/:;=?@[]"),
205            "%21%23%24%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D"
206        );
207    }
208}