hubcaps_ex/
content.rs

1//! Content interface
2use std::fmt;
3use std::ops;
4
5use data_encoding::BASE64;
6use serde::de::{self, Visitor};
7use serde::{Deserialize, Serialize};
8
9use crate::repo_commits::CommitDetails;
10use crate::utils::{percent_encode, PATH};
11use crate::{Future, Github, Stream};
12
13/// Provides access to the content information for a repository
14pub struct Content {
15    github: Github,
16    owner: String,
17    repo: String,
18}
19
20impl Content {
21    #[doc(hidden)]
22    pub fn new<O, R>(github: Github, owner: O, repo: R) -> Self
23    where
24        O: Into<String>,
25        R: Into<String>,
26    {
27        Content {
28            github,
29            owner: owner.into(),
30            repo: repo.into(),
31        }
32    }
33
34    fn path(&self, location: &str, ref_: &str) -> String {
35        // Handle files with spaces and other characters that can mess up the
36        // final URL.
37        let location = percent_encode(location.as_ref(), PATH);
38        let mut path = format!("/repos/{}/{}/contents{}", self.owner, self.repo, location);
39        if !ref_.is_empty() {
40            path += &format!("?ref={}", ref_);
41        }
42        path
43    }
44
45    /// Gets the contents of the location. This could be a file, symlink, or
46    /// submodule. To list the contents of a directory, use `iter`.
47    pub fn get(&self, location: &str, ref_: &str) -> Future<Contents> {
48        self.github.get(&self.path(location, ref_))
49    }
50
51    /// Information on a single file.
52    ///
53    /// GitHub only supports downloading files up to 1 megabyte in size. If you
54    /// need to retrieve larger files, the Git Data API must be used instead.
55    pub fn file(&self, location: &str, ref_: &str) -> Future<File> {
56        self.github.get(&self.path(location, ref_))
57    }
58
59    /// List the root directory.
60    pub fn root(&self, ref_: &str) -> Stream<DirectoryItem> {
61        self.iter("/", ref_)
62    }
63
64    /// Provides a stream over the directory items in `location`.
65    ///
66    /// GitHub limits the number of items returned to 1000 for this API. If you
67    /// need to retrieve more items, the Git Data API must be used instead.
68    pub fn iter(&self, location: &str, ref_: &str) -> Stream<DirectoryItem> {
69        self.github.get_stream(&self.path(location, ref_))
70    }
71
72    /// Creates a file at a specific location in a repository.
73    /// You DO NOT need to base64 encode the content, we will do it for you.
74    pub fn create(&self, location: &str, content: &[u8], message: &str) -> Future<NewFileResponse> {
75        let file = &NewFile {
76            content: BASE64.encode(content),
77            message: message.to_string(),
78            sha: None,
79        };
80        self.github.put(&self.path(location, ""), json!(file))
81    }
82
83    /// Updates a file at a specific location in a repository.
84    /// You DO NOT need to base64 encode the content, we will do it for you.
85    pub fn update(
86        &self,
87        location: &str,
88        content: &[u8],
89        message: &str,
90        sha: &str,
91    ) -> Future<NewFileResponse> {
92        let file = &NewFile {
93            content: BASE64.encode(content),
94            message: message.to_string(),
95            sha: Some(sha.to_string()),
96        };
97        self.github.put(&self.path(location, ""), json!(file))
98    }
99
100    /// Deletes a file.
101    pub fn delete(&self, location: &str, message: &str, sha: &str) -> Future<()> {
102        let file = &NewFile {
103            content: "".to_string(),
104            message: message.to_string(),
105            sha: Some(sha.to_string()),
106        };
107        self.github
108            .delete_message(&self.path(location, ""), json!(file))
109    }
110}
111
112/// Contents of a path in a repository.
113#[derive(Debug, Deserialize)]
114#[serde(rename_all = "snake_case", tag = "type")]
115pub enum Contents {
116    File(File),
117    Symlink(Symlink),
118    Submodule(Submodule),
119}
120
121/// The type of content encoding.
122#[derive(Debug, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum Encoding {
125    Base64,
126    // Are there actually any other encoding types?
127}
128
129#[derive(Debug, Serialize)]
130pub struct NewFile {
131    #[serde(skip_serializing_if = "String::is_empty")]
132    pub content: String,
133    pub message: String,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub sha: Option<String>,
136}
137
138#[derive(Debug, Deserialize)]
139pub struct NewFileResponse {
140    pub commit: CommitDetails,
141}
142
143#[derive(Debug, Deserialize)]
144pub struct File {
145    pub encoding: Encoding,
146    pub size: u32,
147    pub name: String,
148    pub path: String,
149    pub content: DecodedContents,
150    pub sha: String,
151    pub url: String,
152    pub git_url: String,
153    pub html_url: String,
154    pub download_url: String,
155    pub _links: Links,
156}
157
158#[derive(Debug, Deserialize)]
159pub struct DirectoryItem {
160    #[serde(rename = "type")]
161    pub _type: String,
162    pub size: u32,
163    pub name: String,
164    pub path: String,
165    pub sha: String,
166    pub url: String,
167    pub git_url: String,
168    pub html_url: String,
169    pub download_url: Option<String>,
170    pub _links: Links,
171}
172
173#[derive(Debug, Deserialize)]
174pub struct Symlink {
175    pub target: String,
176    pub size: u32,
177    pub name: String,
178    pub path: String,
179    pub sha: String,
180    pub url: String,
181    pub git_url: String,
182    pub html_url: String,
183    pub download_url: String,
184    pub _links: Links,
185}
186
187#[derive(Debug, Deserialize)]
188pub struct Submodule {
189    pub submodule_git_url: String,
190    pub size: u32,
191    pub name: String,
192    pub path: String,
193    pub sha: String,
194    pub url: String,
195    pub git_url: String,
196    pub html_url: String,
197    pub download_url: Option<String>,
198    pub _links: Links,
199}
200
201#[derive(Debug, Deserialize)]
202pub struct Links {
203    pub git: String,
204    #[serde(rename = "self")]
205    pub _self: String,
206    pub html: String,
207}
208
209/// Decoded file contents.
210#[derive(Debug)]
211pub struct DecodedContents(Vec<u8>);
212
213impl Into<Vec<u8>> for DecodedContents {
214    fn into(self) -> Vec<u8> {
215        self.0
216    }
217}
218
219impl AsRef<[u8]> for DecodedContents {
220    fn as_ref(&self) -> &[u8] {
221        &self.0
222    }
223}
224
225impl ops::Deref for DecodedContents {
226    type Target = [u8];
227
228    fn deref(&self) -> &Self::Target {
229        &self.0
230    }
231}
232
233impl<'de> Deserialize<'de> for DecodedContents {
234    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
235    where
236        D: serde::Deserializer<'de>,
237    {
238        struct DecodedContentsVisitor;
239
240        impl<'de> Visitor<'de> for DecodedContentsVisitor {
241            type Value = DecodedContents;
242
243            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244                write!(f, "base64 string")
245            }
246
247            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
248            where
249                E: de::Error,
250            {
251                // GitHub wraps the base64 to column 60. The base64 crate
252                // doesn't handle whitespace, nor does it take a reader, so we
253                // must unfortunately allocate again and remove all new lines.
254                let v = v.replace("\n", "");
255
256                let decoded = base64::decode_config(&v, base64::STANDARD).map_err(|e| match e {
257                    base64::DecodeError::InvalidLength => {
258                        E::invalid_length(v.len(), &"invalid base64 length")
259                    }
260                    base64::DecodeError::InvalidByte(offset, byte) => E::invalid_value(
261                        de::Unexpected::Bytes(&[byte]),
262                        &format!("valid base64 character at offset {}", offset).as_str(),
263                    ),
264                    base64::DecodeError::InvalidLastSymbol(offset, byte) => E::invalid_value(
265                        de::Unexpected::Bytes(&[byte]),
266                        &format!("valid last base64 character at offset {}", offset).as_str(),
267                    ),
268                })?;
269
270                Ok(DecodedContents(decoded))
271            }
272        }
273
274        deserializer.deserialize_str(DecodedContentsVisitor)
275    }
276}