diff options
Diffstat (limited to 'gitolfs3-server/src/api.rs')
-rw-r--r-- | gitolfs3-server/src/api.rs | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/gitolfs3-server/src/api.rs b/gitolfs3-server/src/api.rs new file mode 100644 index 0000000..dba7ada --- /dev/null +++ b/gitolfs3-server/src/api.rs | |||
@@ -0,0 +1,279 @@ | |||
1 | use std::collections::HashMap; | ||
2 | |||
3 | use axum::{ | ||
4 | async_trait, | ||
5 | extract::{rejection, FromRequest, FromRequestParts, Request}, | ||
6 | http::{header, request::Parts, HeaderValue, StatusCode}, | ||
7 | response::{IntoResponse, Response}, | ||
8 | Extension, Json, | ||
9 | }; | ||
10 | use chrono::{DateTime, Utc}; | ||
11 | use gitolfs3_common::{Oid, Operation}; | ||
12 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; | ||
13 | |||
14 | pub const REPO_NOT_FOUND: GitLfsErrorResponse = | ||
15 | make_error_resp(StatusCode::NOT_FOUND, "Repository not found"); | ||
16 | |||
17 | #[derive(Clone)] | ||
18 | pub struct RepositoryName(pub String); | ||
19 | |||
20 | pub struct RepositoryNameRejection; | ||
21 | |||
22 | impl IntoResponse for RepositoryNameRejection { | ||
23 | fn into_response(self) -> Response { | ||
24 | (StatusCode::INTERNAL_SERVER_ERROR, "Missing repository name").into_response() | ||
25 | } | ||
26 | } | ||
27 | |||
28 | #[async_trait] | ||
29 | impl<S: Send + Sync> FromRequestParts<S> for RepositoryName { | ||
30 | type Rejection = RepositoryNameRejection; | ||
31 | |||
32 | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { | ||
33 | let Ok(Extension(repo_name)) = Extension::<Self>::from_request_parts(parts, state).await | ||
34 | else { | ||
35 | return Err(RepositoryNameRejection); | ||
36 | }; | ||
37 | Ok(repo_name) | ||
38 | } | ||
39 | } | ||
40 | |||
41 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)] | ||
42 | pub enum TransferAdapter { | ||
43 | #[serde(rename = "basic")] | ||
44 | Basic, | ||
45 | #[serde(other)] | ||
46 | Unknown, | ||
47 | } | ||
48 | |||
49 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)] | ||
50 | pub enum HashAlgo { | ||
51 | #[serde(rename = "sha256")] | ||
52 | Sha256, | ||
53 | #[serde(other)] | ||
54 | Unknown, | ||
55 | } | ||
56 | |||
57 | impl Default for HashAlgo { | ||
58 | fn default() -> Self { | ||
59 | Self::Sha256 | ||
60 | } | ||
61 | } | ||
62 | |||
63 | #[derive(Debug, Deserialize, PartialEq, Eq, Clone)] | ||
64 | pub struct BatchRequestObject { | ||
65 | pub oid: Oid, | ||
66 | pub size: i64, | ||
67 | } | ||
68 | |||
69 | #[derive(Debug, Serialize, Deserialize, Clone)] | ||
70 | struct BatchRef { | ||
71 | name: String, | ||
72 | } | ||
73 | |||
74 | fn default_transfers() -> Vec<TransferAdapter> { | ||
75 | vec![TransferAdapter::Basic] | ||
76 | } | ||
77 | |||
78 | #[derive(Debug, Deserialize, PartialEq, Eq, Clone)] | ||
79 | pub struct BatchRequest { | ||
80 | pub operation: Operation, | ||
81 | #[serde(default = "default_transfers")] | ||
82 | pub transfers: Vec<TransferAdapter>, | ||
83 | pub objects: Vec<BatchRequestObject>, | ||
84 | #[serde(default)] | ||
85 | pub hash_algo: HashAlgo, | ||
86 | } | ||
87 | |||
88 | #[derive(Debug, Clone)] | ||
89 | pub struct GitLfsJson<T>(pub Json<T>); | ||
90 | |||
91 | pub const LFS_MIME: &str = "application/vnd.git-lfs+json"; | ||
92 | |||
93 | pub enum GitLfsJsonRejection { | ||
94 | Json(rejection::JsonRejection), | ||
95 | MissingGitLfsJsonContentType, | ||
96 | } | ||
97 | |||
98 | impl IntoResponse for GitLfsJsonRejection { | ||
99 | fn into_response(self) -> Response { | ||
100 | match self { | ||
101 | Self::Json(rej) => rej.into_response(), | ||
102 | Self::MissingGitLfsJsonContentType => make_error_resp( | ||
103 | StatusCode::UNSUPPORTED_MEDIA_TYPE, | ||
104 | &format!("Expected request with `Content-Type: {LFS_MIME}`"), | ||
105 | ) | ||
106 | .into_response(), | ||
107 | } | ||
108 | } | ||
109 | } | ||
110 | |||
111 | pub fn is_git_lfs_json_mimetype(mimetype: &str) -> bool { | ||
112 | let Ok(mime) = mimetype.parse::<mime::Mime>() else { | ||
113 | return false; | ||
114 | }; | ||
115 | if mime.type_() != mime::APPLICATION | ||
116 | || mime.subtype() != "vnd.git-lfs" | ||
117 | || mime.suffix() != Some(mime::JSON) | ||
118 | { | ||
119 | return false; | ||
120 | } | ||
121 | match mime.get_param(mime::CHARSET) { | ||
122 | Some(mime::UTF_8) | None => true, | ||
123 | Some(_) => false, | ||
124 | } | ||
125 | } | ||
126 | |||
127 | fn has_git_lfs_json_content_type(req: &Request) -> bool { | ||
128 | let Some(content_type) = req.headers().get(header::CONTENT_TYPE) else { | ||
129 | return false; | ||
130 | }; | ||
131 | let Ok(content_type) = content_type.to_str() else { | ||
132 | return false; | ||
133 | }; | ||
134 | is_git_lfs_json_mimetype(content_type) | ||
135 | } | ||
136 | |||
137 | #[async_trait] | ||
138 | impl<T, S> FromRequest<S> for GitLfsJson<T> | ||
139 | where | ||
140 | T: DeserializeOwned, | ||
141 | S: Send + Sync, | ||
142 | { | ||
143 | type Rejection = GitLfsJsonRejection; | ||
144 | |||
145 | async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { | ||
146 | if !has_git_lfs_json_content_type(&req) { | ||
147 | return Err(GitLfsJsonRejection::MissingGitLfsJsonContentType); | ||
148 | } | ||
149 | Json::<T>::from_request(req, state) | ||
150 | .await | ||
151 | .map(GitLfsJson) | ||
152 | .map_err(GitLfsJsonRejection::Json) | ||
153 | } | ||
154 | } | ||
155 | |||
156 | impl<T: Serialize> IntoResponse for GitLfsJson<T> { | ||
157 | fn into_response(self) -> Response { | ||
158 | let GitLfsJson(json) = self; | ||
159 | let mut resp = json.into_response(); | ||
160 | resp.headers_mut().insert( | ||
161 | header::CONTENT_TYPE, | ||
162 | HeaderValue::from_static("application/vnd.git-lfs+json; charset=utf-8"), | ||
163 | ); | ||
164 | resp | ||
165 | } | ||
166 | } | ||
167 | |||
168 | #[derive(Debug, Serialize)] | ||
169 | pub struct GitLfsErrorData<'a> { | ||
170 | pub message: &'a str, | ||
171 | } | ||
172 | |||
173 | pub type GitLfsErrorResponse<'a> = (StatusCode, GitLfsJson<GitLfsErrorData<'a>>); | ||
174 | |||
175 | pub const fn make_error_resp(code: StatusCode, message: &str) -> GitLfsErrorResponse { | ||
176 | (code, GitLfsJson(Json(GitLfsErrorData { message }))) | ||
177 | } | ||
178 | |||
179 | #[derive(Debug, Serialize, Clone)] | ||
180 | pub struct BatchResponseObjectAction { | ||
181 | pub href: String, | ||
182 | #[serde(skip_serializing_if = "HashMap::is_empty")] | ||
183 | pub header: HashMap<String, String>, | ||
184 | pub expires_at: DateTime<Utc>, | ||
185 | } | ||
186 | |||
187 | #[derive(Default, Debug, Serialize, Clone)] | ||
188 | pub struct BatchResponseObjectActions { | ||
189 | #[serde(skip_serializing_if = "Option::is_none")] | ||
190 | pub upload: Option<BatchResponseObjectAction>, | ||
191 | #[serde(skip_serializing_if = "Option::is_none")] | ||
192 | pub download: Option<BatchResponseObjectAction>, | ||
193 | #[serde(skip_serializing_if = "Option::is_none")] | ||
194 | pub verify: Option<BatchResponseObjectAction>, | ||
195 | } | ||
196 | |||
197 | #[derive(Debug, Clone, Serialize)] | ||
198 | pub struct BatchResponseObjectError { | ||
199 | pub code: u16, | ||
200 | pub message: String, | ||
201 | } | ||
202 | |||
203 | #[derive(Debug, Serialize, Clone)] | ||
204 | pub struct BatchResponseObject { | ||
205 | pub oid: Oid, | ||
206 | pub size: i64, | ||
207 | #[serde(skip_serializing_if = "Option::is_none")] | ||
208 | pub authenticated: Option<bool>, | ||
209 | pub actions: BatchResponseObjectActions, | ||
210 | #[serde(skip_serializing_if = "Option::is_none")] | ||
211 | pub error: Option<BatchResponseObjectError>, | ||
212 | } | ||
213 | |||
214 | impl BatchResponseObject { | ||
215 | pub fn error( | ||
216 | obj: &BatchRequestObject, | ||
217 | code: StatusCode, | ||
218 | message: String, | ||
219 | ) -> BatchResponseObject { | ||
220 | BatchResponseObject { | ||
221 | oid: obj.oid, | ||
222 | size: obj.size, | ||
223 | authenticated: None, | ||
224 | actions: Default::default(), | ||
225 | error: Some(BatchResponseObjectError { | ||
226 | code: code.as_u16(), | ||
227 | message, | ||
228 | }), | ||
229 | } | ||
230 | } | ||
231 | } | ||
232 | |||
233 | #[derive(Debug, Serialize, Clone)] | ||
234 | pub struct BatchResponse { | ||
235 | pub transfer: TransferAdapter, | ||
236 | pub objects: Vec<BatchResponseObject>, | ||
237 | pub hash_algo: HashAlgo, | ||
238 | } | ||
239 | |||
240 | #[test] | ||
241 | fn test_mimetype() { | ||
242 | assert!(is_git_lfs_json_mimetype("application/vnd.git-lfs+json")); | ||
243 | assert!(!is_git_lfs_json_mimetype("application/vnd.git-lfs")); | ||
244 | assert!(!is_git_lfs_json_mimetype("application/json")); | ||
245 | assert!(is_git_lfs_json_mimetype( | ||
246 | "application/vnd.git-lfs+json; charset=utf-8" | ||
247 | )); | ||
248 | assert!(is_git_lfs_json_mimetype( | ||
249 | "application/vnd.git-lfs+json; charset=UTF-8" | ||
250 | )); | ||
251 | assert!(!is_git_lfs_json_mimetype( | ||
252 | "application/vnd.git-lfs+json; charset=ISO-8859-1" | ||
253 | )); | ||
254 | } | ||
255 | |||
256 | #[test] | ||
257 | fn test_deserialize() { | ||
258 | let json = r#"{"operation":"upload","objects":[{"oid":"8f4123f9a7181f488c5e111d82cefd992e461ae5df01fd2254399e6e670b2d3c","size":170904}], | ||
259 | "transfers":["lfs-standalone-file","basic","ssh"],"ref":{"name":"refs/heads/main"},"hash_algo":"sha256"}"#; | ||
260 | let expected = BatchRequest { | ||
261 | operation: Operation::Upload, | ||
262 | objects: vec![BatchRequestObject { | ||
263 | oid: "8f4123f9a7181f488c5e111d82cefd992e461ae5df01fd2254399e6e670b2d3c" | ||
264 | .parse() | ||
265 | .unwrap(), | ||
266 | size: 170904, | ||
267 | }], | ||
268 | transfers: vec![ | ||
269 | TransferAdapter::Unknown, | ||
270 | TransferAdapter::Basic, | ||
271 | TransferAdapter::Unknown, | ||
272 | ], | ||
273 | hash_algo: HashAlgo::Sha256, | ||
274 | }; | ||
275 | assert_eq!( | ||
276 | serde_json::from_str::<BatchRequest>(json).unwrap(), | ||
277 | expected | ||
278 | ); | ||
279 | } | ||