aboutsummaryrefslogtreecommitdiffstats
path: root/gitolfs3-server/src/api.rs
diff options
context:
space:
mode:
Diffstat (limited to 'gitolfs3-server/src/api.rs')
-rw-r--r--gitolfs3-server/src/api.rs279
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 @@
1use std::collections::HashMap;
2
3use 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};
10use chrono::{DateTime, Utc};
11use gitolfs3_common::{Oid, Operation};
12use serde::{de::DeserializeOwned, Deserialize, Serialize};
13
14pub const REPO_NOT_FOUND: GitLfsErrorResponse =
15 make_error_resp(StatusCode::NOT_FOUND, "Repository not found");
16
17#[derive(Clone)]
18pub struct RepositoryName(pub String);
19
20pub struct RepositoryNameRejection;
21
22impl 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]
29impl<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)]
42pub enum TransferAdapter {
43 #[serde(rename = "basic")]
44 Basic,
45 #[serde(other)]
46 Unknown,
47}
48
49#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
50pub enum HashAlgo {
51 #[serde(rename = "sha256")]
52 Sha256,
53 #[serde(other)]
54 Unknown,
55}
56
57impl Default for HashAlgo {
58 fn default() -> Self {
59 Self::Sha256
60 }
61}
62
63#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
64pub struct BatchRequestObject {
65 pub oid: Oid,
66 pub size: i64,
67}
68
69#[derive(Debug, Serialize, Deserialize, Clone)]
70struct BatchRef {
71 name: String,
72}
73
74fn default_transfers() -> Vec<TransferAdapter> {
75 vec![TransferAdapter::Basic]
76}
77
78#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
79pub 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)]
89pub struct GitLfsJson<T>(pub Json<T>);
90
91pub const LFS_MIME: &str = "application/vnd.git-lfs+json";
92
93pub enum GitLfsJsonRejection {
94 Json(rejection::JsonRejection),
95 MissingGitLfsJsonContentType,
96}
97
98impl 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
111pub 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
127fn 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]
138impl<T, S> FromRequest<S> for GitLfsJson<T>
139where
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
156impl<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)]
169pub struct GitLfsErrorData<'a> {
170 pub message: &'a str,
171}
172
173pub type GitLfsErrorResponse<'a> = (StatusCode, GitLfsJson<GitLfsErrorData<'a>>);
174
175pub const fn make_error_resp(code: StatusCode, message: &str) -> GitLfsErrorResponse {
176 (code, GitLfsJson(Json(GitLfsErrorData { message })))
177}
178
179#[derive(Debug, Serialize, Clone)]
180pub 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)]
188pub 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)]
198pub struct BatchResponseObjectError {
199 pub code: u16,
200 pub message: String,
201}
202
203#[derive(Debug, Serialize, Clone)]
204pub 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
214impl 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)]
234pub struct BatchResponse {
235 pub transfer: TransferAdapter,
236 pub objects: Vec<BatchResponseObject>,
237 pub hash_algo: HashAlgo,
238}
239
240#[test]
241fn 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]
257fn 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}