gitlab/api/projects/repository/files/
create.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::str;
8
9use base64::Engine;
10use derive_builder::Builder;
11use log::warn;
12
13use crate::api::common::{self, NameOrId};
14use crate::api::endpoint_prelude::*;
15use crate::api::ParamValue;
16
17/// Encodings for uploading file contents through an HTTP request.
18#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum Encoding {
21    /// No special encoding.
22    ///
23    /// Only supports UTF-8 content.
24    #[default]
25    Text,
26    /// Base64-encoding.
27    ///
28    /// Supports representing binary content.
29    Base64,
30}
31
32impl Encoding {
33    pub(crate) fn is_binary_safe(self) -> bool {
34        match self {
35            Encoding::Text => false,
36            Encoding::Base64 => true,
37        }
38    }
39
40    pub(crate) fn encode<'a>(self, as_string: Option<&'a str>, content: &'a [u8]) -> Cow<'a, str> {
41        match self {
42            Encoding::Text => {
43                if let Some(string) = as_string {
44                    string.into()
45                } else {
46                    panic!("attempting to encode non-utf8 content using text!");
47                }
48            },
49            Encoding::Base64 => {
50                let engine = base64::engine::general_purpose::STANDARD;
51                engine.encode(content).into()
52            },
53        }
54    }
55
56    pub(crate) fn as_str(self) -> &'static str {
57        match self {
58            Encoding::Text => "text",
59            Encoding::Base64 => "base64",
60        }
61    }
62}
63
64impl ParamValue<'static> for Encoding {
65    fn as_value(&self) -> Cow<'static, str> {
66        self.as_str().into()
67    }
68}
69
70/// Create a new file in a project.
71#[derive(Debug, Builder, Clone)]
72#[builder(setter(strip_option))]
73pub struct CreateFile<'a> {
74    /// The project to create a file within.
75    #[builder(setter(into))]
76    project: NameOrId<'a>,
77    /// The path to the file in the repository.
78    ///
79    /// This is automatically escaped as needed.
80    #[builder(setter(into))]
81    file_path: Cow<'a, str>,
82    /// The branch to use for the new commit.
83    #[builder(setter(into))]
84    branch: Cow<'a, str>,
85    /// The content of the new file.
86    ///
87    /// This will automatically be encoded according to the `encoding` parameter.
88    #[builder(setter(into))]
89    content: Cow<'a, [u8]>,
90    /// The commit message to use.
91    #[builder(setter(into))]
92    commit_message: Cow<'a, str>,
93
94    /// Where to start the branch from (if it doesn't already exist).
95    #[builder(setter(into), default)]
96    start_branch: Option<Cow<'a, str>>,
97    /// The encoding to use for the content.
98    ///
99    /// Note that if `text` is requested and `content` contains non-UTF-8 content, a warning will
100    /// be generated and a binary-safe encoding used instead.
101    #[builder(setter(into), default)]
102    encoding: Option<Encoding>,
103    /// The email of the author for the new commit.
104    #[builder(setter(into), default)]
105    author_email: Option<Cow<'a, str>>,
106    /// The name of the author for the new commit.
107    #[builder(setter(into), default)]
108    author_name: Option<Cow<'a, str>>,
109
110    /// Whether the file is executable or not.
111    #[builder(default)]
112    execute_filemode: Option<bool>,
113}
114
115impl<'a> CreateFile<'a> {
116    /// Create a builder for the endpoint.
117    pub fn builder() -> CreateFileBuilder<'a> {
118        CreateFileBuilder::default()
119    }
120}
121
122const SAFE_ENCODING: Encoding = Encoding::Base64;
123
124impl Endpoint for CreateFile<'_> {
125    fn method(&self) -> Method {
126        Method::POST
127    }
128
129    fn endpoint(&self) -> Cow<'static, str> {
130        format!(
131            "projects/{}/repository/files/{}",
132            self.project,
133            common::path_escaped(&self.file_path),
134        )
135        .into()
136    }
137
138    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
139        let mut params = FormParams::default();
140
141        params
142            .push("branch", &self.branch)
143            .push("commit_message", &self.commit_message)
144            .push_opt("start_branch", self.start_branch.as_ref())
145            .push_opt("author_email", self.author_email.as_ref())
146            .push_opt("author_name", self.author_name.as_ref())
147            .push_opt("execute_filemode", self.execute_filemode);
148
149        let content = str::from_utf8(&self.content);
150        let needs_encoding = content.is_err();
151        let encoding = self.encoding.unwrap_or_default();
152        let actual_encoding = if needs_encoding && !encoding.is_binary_safe() {
153            warn!(
154                "forcing the encoding to {} due to utf-8 unsafe content",
155                SAFE_ENCODING.as_str(),
156            );
157            SAFE_ENCODING
158        } else {
159            encoding
160        };
161        params.push(
162            "content",
163            actual_encoding.encode(content.ok(), &self.content),
164        );
165        self.encoding
166            // Use the actual encoding.
167            .map(|_| actual_encoding)
168            // Force the encoding if we're not using the default.
169            .or_else(|| {
170                if actual_encoding != Encoding::default() {
171                    Some(actual_encoding)
172                } else {
173                    None
174                }
175            })
176            .map(|value| params.push("encoding", value));
177
178        params.into_body()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use http::Method;
185
186    use crate::api::projects::repository::files::{CreateFile, CreateFileBuilderError, Encoding};
187    use crate::api::{self, Query};
188    use crate::test::client::{ExpectedUrl, SingleTestClient};
189
190    #[test]
191    fn encoding_default() {
192        assert_eq!(Encoding::default(), Encoding::Text);
193    }
194
195    #[test]
196    fn encoding_is_binary_safe() {
197        let items = &[(Encoding::Text, false), (Encoding::Base64, true)];
198
199        for (i, s) in items {
200            assert_eq!(i.is_binary_safe(), *s);
201        }
202    }
203
204    #[test]
205    fn encoding_encode_text() {
206        let encoding = Encoding::Text;
207        assert_eq!(encoding.encode(Some("foo"), b"foo"), "foo");
208    }
209
210    #[test]
211    #[should_panic = "attempting to encode non-utf8 content using text!"]
212    fn encoding_encode_text_bad() {
213        let encoding = Encoding::Text;
214        encoding.encode(None, b"\xff");
215    }
216
217    #[test]
218    fn encoding_encode_base64() {
219        let encoding = Encoding::Base64;
220        assert_eq!(encoding.encode(None, b"foo"), "Zm9v");
221    }
222
223    #[test]
224    fn encoding_as_str() {
225        let items = &[(Encoding::Text, "text"), (Encoding::Base64, "base64")];
226
227        for (i, s) in items {
228            assert_eq!(i.as_str(), *s);
229        }
230    }
231
232    #[test]
233    fn all_parameters_are_needed() {
234        let err = CreateFile::builder().build().unwrap_err();
235        crate::test::assert_missing_field!(err, CreateFileBuilderError, "project");
236    }
237
238    #[test]
239    fn project_is_required() {
240        let err = CreateFile::builder()
241            .file_path("new/file")
242            .branch("master")
243            .commit_message("commit message")
244            .content(&b"contents"[..])
245            .build()
246            .unwrap_err();
247        crate::test::assert_missing_field!(err, CreateFileBuilderError, "project");
248    }
249
250    #[test]
251    fn file_path_is_required() {
252        let err = CreateFile::builder()
253            .project(1)
254            .branch("master")
255            .commit_message("commit message")
256            .content(&b"contents"[..])
257            .build()
258            .unwrap_err();
259        crate::test::assert_missing_field!(err, CreateFileBuilderError, "file_path");
260    }
261
262    #[test]
263    fn branch_is_required() {
264        let err = CreateFile::builder()
265            .project(1)
266            .file_path("new/file")
267            .commit_message("commit message")
268            .content(&b"contents"[..])
269            .build()
270            .unwrap_err();
271        crate::test::assert_missing_field!(err, CreateFileBuilderError, "branch");
272    }
273
274    #[test]
275    fn commit_message_is_required() {
276        let err = CreateFile::builder()
277            .project(1)
278            .file_path("new/file")
279            .branch("master")
280            .content(&b"contents"[..])
281            .build()
282            .unwrap_err();
283        crate::test::assert_missing_field!(err, CreateFileBuilderError, "commit_message");
284    }
285
286    #[test]
287    fn content_is_required() {
288        let err = CreateFile::builder()
289            .project(1)
290            .file_path("new/file")
291            .branch("master")
292            .commit_message("commit message")
293            .build()
294            .unwrap_err();
295        crate::test::assert_missing_field!(err, CreateFileBuilderError, "content");
296    }
297
298    #[test]
299    fn sufficient_parameters() {
300        CreateFile::builder()
301            .project(1)
302            .file_path("new/file")
303            .branch("master")
304            .commit_message("commit message")
305            .content(&b"contents"[..])
306            .build()
307            .unwrap();
308    }
309
310    #[test]
311    fn endpoint() {
312        let endpoint = ExpectedUrl::builder()
313            .method(Method::POST)
314            .endpoint("projects/simple%2Fproject/repository/files/path%2Fto%2Ffile")
315            .content_type("application/x-www-form-urlencoded")
316            .body_str(concat!(
317                "branch=branch",
318                "&commit_message=commit+message",
319                "&content=file+contents",
320            ))
321            .build()
322            .unwrap();
323        let client = SingleTestClient::new_raw(endpoint, "");
324
325        let endpoint = CreateFile::builder()
326            .project("simple/project")
327            .file_path("path/to/file")
328            .branch("branch")
329            .content(&b"file contents"[..])
330            .commit_message("commit message")
331            .build()
332            .unwrap();
333        api::ignore(endpoint).query(&client).unwrap();
334    }
335
336    #[test]
337    fn endpoint_start_branch() {
338        let endpoint = ExpectedUrl::builder()
339            .method(Method::POST)
340            .endpoint("projects/simple%2Fproject/repository/files/path%2Fto%2Ffile")
341            .content_type("application/x-www-form-urlencoded")
342            .body_str(concat!(
343                "branch=branch",
344                "&commit_message=commit+message",
345                "&start_branch=master",
346                "&content=file+contents",
347            ))
348            .build()
349            .unwrap();
350        let client = SingleTestClient::new_raw(endpoint, "");
351
352        let endpoint = CreateFile::builder()
353            .project("simple/project")
354            .file_path("path/to/file")
355            .branch("branch")
356            .content(&b"file contents"[..])
357            .commit_message("commit message")
358            .start_branch("master")
359            .build()
360            .unwrap();
361        api::ignore(endpoint).query(&client).unwrap();
362    }
363
364    #[test]
365    fn endpoint_encoding() {
366        let endpoint = ExpectedUrl::builder()
367            .method(Method::POST)
368            .endpoint("projects/simple%2Fproject/repository/files/path%2Fto%2Ffile")
369            .content_type("application/x-www-form-urlencoded")
370            .body_str(concat!(
371                "branch=branch",
372                "&commit_message=commit+message",
373                "&content=ZmlsZSBjb250ZW50cw%3D%3D",
374                "&encoding=base64",
375            ))
376            .build()
377            .unwrap();
378        let client = SingleTestClient::new_raw(endpoint, "");
379
380        let endpoint = CreateFile::builder()
381            .project("simple/project")
382            .file_path("path/to/file")
383            .branch("branch")
384            .content(&b"file contents"[..])
385            .commit_message("commit message")
386            .encoding(Encoding::Base64)
387            .build()
388            .unwrap();
389        api::ignore(endpoint).query(&client).unwrap();
390    }
391
392    #[test]
393    fn endpoint_encoding_upgrade() {
394        let endpoint = ExpectedUrl::builder()
395            .method(Method::POST)
396            .endpoint("projects/simple%2Fproject/repository/files/path%2Fto%2Ffile")
397            .content_type("application/x-www-form-urlencoded")
398            .body_str(concat!(
399                "branch=branch",
400                "&commit_message=commit+message",
401                "&content=%2Fw%3D%3D",
402                "&encoding=base64",
403            ))
404            .build()
405            .unwrap();
406        let client = SingleTestClient::new_raw(endpoint, "");
407
408        let endpoint = CreateFile::builder()
409            .project("simple/project")
410            .file_path("path/to/file")
411            .branch("branch")
412            .content(&b"\xff"[..])
413            .commit_message("commit message")
414            .build()
415            .unwrap();
416        api::ignore(endpoint).query(&client).unwrap();
417    }
418
419    #[test]
420    fn endpoint_author_email() {
421        let endpoint = ExpectedUrl::builder()
422            .method(Method::POST)
423            .endpoint("projects/simple%2Fproject/repository/files/path%2Fto%2Ffile")
424            .content_type("application/x-www-form-urlencoded")
425            .body_str(concat!(
426                "branch=branch",
427                "&commit_message=commit+message",
428                "&author_email=author%40email.invalid",
429                "&content=file+contents",
430            ))
431            .build()
432            .unwrap();
433        let client = SingleTestClient::new_raw(endpoint, "");
434
435        let endpoint = CreateFile::builder()
436            .project("simple/project")
437            .file_path("path/to/file")
438            .branch("branch")
439            .content(&b"file contents"[..])
440            .commit_message("commit message")
441            .author_email("author@email.invalid")
442            .build()
443            .unwrap();
444        api::ignore(endpoint).query(&client).unwrap();
445    }
446
447    #[test]
448    fn endpoint_author_name() {
449        let endpoint = ExpectedUrl::builder()
450            .method(Method::POST)
451            .endpoint("projects/simple%2Fproject/repository/files/path%2Fto%2Ffile")
452            .content_type("application/x-www-form-urlencoded")
453            .body_str(concat!(
454                "branch=branch",
455                "&commit_message=commit+message",
456                "&author_name=Arthur+Developer",
457                "&content=file+contents",
458            ))
459            .build()
460            .unwrap();
461        let client = SingleTestClient::new_raw(endpoint, "");
462
463        let endpoint = CreateFile::builder()
464            .project("simple/project")
465            .file_path("path/to/file")
466            .branch("branch")
467            .content(&b"file contents"[..])
468            .commit_message("commit message")
469            .author_name("Arthur Developer")
470            .build()
471            .unwrap();
472        api::ignore(endpoint).query(&client).unwrap();
473    }
474
475    #[test]
476    fn endpoint_execute_filemode() {
477        let endpoint = ExpectedUrl::builder()
478            .method(Method::POST)
479            .endpoint("projects/simple%2Fproject/repository/files/path%2Fto%2Ffile")
480            .content_type("application/x-www-form-urlencoded")
481            .body_str(concat!(
482                "branch=branch",
483                "&commit_message=commit+message",
484                "&execute_filemode=false",
485                "&content=file+contents",
486            ))
487            .build()
488            .unwrap();
489        let client = SingleTestClient::new_raw(endpoint, "");
490
491        let endpoint = CreateFile::builder()
492            .project("simple/project")
493            .file_path("path/to/file")
494            .branch("branch")
495            .content(&b"file contents"[..])
496            .commit_message("commit message")
497            .execute_filemode(false)
498            .build()
499            .unwrap();
500        api::ignore(endpoint).query(&client).unwrap();
501    }
502}