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
15pub 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 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 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 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 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 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 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 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 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#[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
149pub 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}