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#[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 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 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}