automatons_github/task/
get_file.rs

1use anyhow::Context;
2use base64::decode;
3use serde::Deserialize;
4use serde_json::Value;
5use url::Url;
6
7use automatons::Error;
8
9use crate::client::GitHubClient;
10use crate::resource::{File, Login, RepositoryName};
11
12/// Gets a file in a repository
13///
14/// Gets the contents of a file in a repository.
15///
16/// # Size limits
17///
18/// The task only supports files that are smaller than 1MB.
19///
20/// https://docs.github.com/en/rest/repos/contents#get-repository-content
21#[derive(Copy, Clone, Debug)]
22pub struct GetFile<'a> {
23    github_client: &'a GitHubClient,
24    owner: &'a Login,
25    repository: &'a RepositoryName,
26    path: &'a str,
27}
28
29impl<'a> GetFile<'a> {
30    /// Initializes the task
31    pub fn new(
32        github_client: &'a GitHubClient,
33        owner: &'a Login,
34        repository: &'a RepositoryName,
35        path: &'a str,
36    ) -> Self {
37        Self {
38            github_client,
39            owner,
40            repository,
41            path,
42        }
43    }
44
45    /// Gets a file in a repository
46    ///
47    /// Gets the contents of a file in a repository.
48    pub async fn execute(&self) -> Result<File, Error> {
49        let url = format!(
50            "/repos/{}/{}/contents/{}",
51            self.owner.get(),
52            self.repository.get(),
53            self.path
54        );
55
56        let payload = self.github_client.get(&url).await?;
57
58        let body = match payload {
59            GetFileResponse::Success(body) => body,
60            GetFileResponse::Error(_) => return Err(Error::NotFound(url)),
61        };
62
63        if body.is_array() {
64            Err(Error::Serialization(
65                "failed to handle unsupported directory payload".into(),
66            ))
67        } else {
68            let payload: GetFilePayload = serde_json::from_value(body).map_err(|_| {
69                Error::Serialization(
70                    "failed to deserialize payload from GitHub's contents API".into(),
71                )
72            })?;
73
74            File::try_from(payload)
75        }
76    }
77}
78
79#[derive(Clone, Eq, PartialEq, Debug, Deserialize)]
80#[serde(untagged)]
81enum GetFileResponse {
82    Error(GetFileErrorPayload),
83    Success(Value),
84}
85
86#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize)]
87struct GetFileErrorPayload {
88    message: String,
89}
90
91#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize)]
92#[serde(rename_all = "snake_case", tag = "type")]
93enum GetFilePayload {
94    Directory,
95    File(Box<FilePayload>),
96    Submodule,
97    Symlink,
98}
99
100#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize)]
101struct FilePayload {
102    encoding: FileEncoding,
103    size: u64,
104    name: String,
105    path: String,
106    content: String,
107    sha: String,
108    url: Url,
109    git_url: Url,
110    html_url: Url,
111    download_url: Url,
112}
113
114#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize)]
115#[serde(rename_all = "snake_case")]
116enum FileEncoding {
117    Base64,
118}
119
120impl TryFrom<GetFilePayload> for File {
121    type Error = Error;
122
123    fn try_from(value: GetFilePayload) -> Result<Self, Self::Error> {
124        let payload = match value {
125            GetFilePayload::Directory => Err(Error::Serialization(
126                "failed to handle unsupported directory payload".into(),
127            )),
128            GetFilePayload::File(payload) => Ok(payload),
129            GetFilePayload::Submodule => Err(Error::Serialization(
130                "failed to handle unsupported submodule payload".into(),
131            )),
132            GetFilePayload::Symlink => Err(Error::Serialization(
133                "failed to handle unsupported symlink payload".into(),
134            )),
135        }?;
136
137        let sanitized_content = &payload.content.replace('\n', "");
138        let content =
139            decode(sanitized_content).context("failed to decode Base64 encoded file content")?;
140
141        Ok(File::new(
142            payload.name,
143            payload.path,
144            content,
145            payload.sha.into(),
146            payload.url,
147            payload.git_url,
148            payload.html_url,
149            payload.download_url,
150        ))
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use mockito::mock;
157
158    use automatons::Error;
159
160    use crate::resource::{Login, RepositoryName};
161    use crate::testing::client::github_client;
162    use crate::testing::contents::{
163        mock_get_contents_directory, mock_get_contents_file, mock_get_contents_submodule,
164        mock_get_contents_symlink,
165    };
166    use crate::testing::token::mock_installation_access_tokens;
167
168    use super::GetFile;
169
170    #[tokio::test]
171    async fn get_file_with_file() {
172        let _token_mock = mock_installation_access_tokens();
173        let _content_mock = mock_get_contents_file();
174
175        let github_client = github_client();
176        let login = Login::new("octokit");
177        let repository = RepositoryName::new("octokit.rb");
178        let path = "README.md";
179
180        let task = GetFile::new(&github_client, &login, &repository, path);
181
182        let file = task.execute().await.unwrap();
183
184        assert_eq!("README.md", file.name());
185    }
186
187    #[tokio::test]
188    async fn get_file_with_directory() {
189        let _token_mock = mock_installation_access_tokens();
190        let _content_mock = mock_get_contents_directory();
191
192        let github_client = github_client();
193        let login = Login::new("octokit");
194        let repository = RepositoryName::new("octokit.rb");
195        let path = "lib/octokit";
196
197        let task = GetFile::new(&github_client, &login, &repository, path);
198
199        let error = task.execute().await.unwrap_err();
200        println!("{:?}", error);
201
202        assert!(matches!(error, Error::Serialization(_)));
203    }
204
205    #[tokio::test]
206    async fn get_file_with_symlink() {
207        let _token_mock = mock_installation_access_tokens();
208        let _content_mock = mock_get_contents_symlink();
209
210        let github_client = github_client();
211        let login = Login::new("octokit");
212        let repository = RepositoryName::new("octokit.rb");
213        let path = "bin/some-symlink";
214
215        let task = GetFile::new(&github_client, &login, &repository, path);
216
217        let error = task.execute().await.unwrap_err();
218
219        assert!(matches!(error, Error::Serialization(_)));
220    }
221
222    #[tokio::test]
223    async fn get_file_with_submodule() {
224        let _token_mock = mock_installation_access_tokens();
225        let _content_mock = mock_get_contents_submodule();
226
227        let github_client = github_client();
228        let login = Login::new("jquery");
229        let repository = RepositoryName::new("jquery");
230        let path = "test/qunit";
231
232        let task = GetFile::new(&github_client, &login, &repository, path);
233
234        let error = task.execute().await.unwrap_err();
235
236        assert!(matches!(error, Error::Serialization(_)));
237    }
238
239    #[tokio::test]
240    async fn get_file_not_found() {
241        let _token_mock = mock_installation_access_tokens();
242
243        let _content_mock = mock("GET", "/repos/devxbots/automatons/contents/README.md")
244            .with_status(404)
245            .with_body(r#"
246                {
247                    "message": "Not Found",
248                    "documentation_url": "https://docs.github.com/rest/reference/repos#get-repository-content"
249                }
250            "#).create();
251
252        let github_client = github_client();
253        let login = Login::new("devxbots");
254        let repository = RepositoryName::new("automatons");
255        let path = "README.md";
256
257        let task = GetFile::new(&github_client, &login, &repository, path);
258
259        let error = task.execute().await.unwrap_err();
260
261        assert!(matches!(error, Error::NotFound(_)));
262    }
263}