1use super::ObjectTransferAccess;
4use crate::{AbsolutePath, ContentRef, InodeId, NamespaceId, RevisionNo, SnapshotId};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
10#[serde(deny_unknown_fields)]
11pub struct CreateDownloadRequest {
12 pub path: AbsolutePath,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
17 #[cfg_attr(feature = "openapi", schema(nullable = false))]
18 pub revision_no: Option<RevisionNo>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
22 #[cfg_attr(feature = "openapi", schema(nullable = false))]
23 pub snapshot_id: Option<SnapshotId>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct CreateDownloadResponse {
32 pub namespace_id: NamespaceId,
34 pub path: AbsolutePath,
36 pub revision_no: RevisionNo,
38 pub content_ref: ContentRef,
40 pub access: ObjectTransferAccess,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
47pub struct CreateDownloadByInodeResponse {
48 pub namespace_id: NamespaceId,
50 #[serde(with = "crate::public_inode_id")]
52 pub inode_id: InodeId,
53 pub revision_no: RevisionNo,
55 pub content_ref: ContentRef,
57 pub access: ObjectTransferAccess,
59}
60
61#[cfg(test)]
62mod tests {
63 use super::{CreateDownloadByInodeResponse, CreateDownloadRequest, CreateDownloadResponse};
64 use crate::v0::ObjectTransferAccess;
65 use crate::{AbsolutePath, ContentId, ContentRef, NamespaceId, RevisionNo, SnapshotId};
66 use std::collections::BTreeMap;
67
68 fn absolute_path() -> AbsolutePath {
69 AbsolutePath::parse("/docs/report.txt").expect("absolute path")
70 }
71
72 fn content_ref() -> ContentRef {
73 ContentRef::blob_v1(
74 crate::NamespaceId::parse("demo").expect("namespace id"),
75 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
76 b"hello",
77 )
78 }
79
80 fn content_ref_json() -> serde_json::Value {
81 serde_json::json!({
82 "kind": "blob_v1",
83 "owner_namespace_id": "demo",
84 "content_id": "con_0123456789abcdef0123456789abcdef",
85 "size_bytes": 5,
86 "checksum": {
87 "algorithm": "sha256",
88 "value": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
89 }
90 })
91 }
92
93 #[test]
94 fn a_create_download_request_decodes_revision_and_snapshot_selectors() {
95 let request: CreateDownloadRequest =
96 serde_json::from_str(r#"{"path":"/docs/report.txt"}"#).expect("decode request");
97 assert_eq!(request.path, absolute_path());
98 assert_eq!(request.revision_no, None);
99
100 let pinned: CreateDownloadRequest =
101 serde_json::from_str(r#"{"path":"/docs/report.txt","revision_no":3}"#)
102 .expect("decode pinned request");
103 assert_eq!(pinned.revision_no, Some(RevisionNo(3)));
104
105 let snapshot_id =
106 SnapshotId::parse("pin_00000000000000000001-0000000000000002").expect("snapshot id");
107 for revision_no in [None, Some(RevisionNo(3))] {
108 let request: CreateDownloadRequest = serde_json::from_value(serde_json::json!({
109 "path": "/docs/report.txt",
110 "revision_no": revision_no,
111 "snapshot_id": snapshot_id,
112 }))
113 .expect("decode snapshot request");
114 assert_eq!(request.snapshot_id, Some(snapshot_id.clone()));
115 assert_eq!(request.revision_no, revision_no);
116 }
117 assert!(serde_json::from_str::<CreateDownloadRequest>(
118 r#"{"path":"/docs/report.txt","snapshot_id":"invalid"}"#,
119 )
120 .is_err());
121
122 assert!(
123 serde_json::from_str::<CreateDownloadRequest>(
124 r#"{"path":"/docs/report.txt","content_id":"con_0123456789abcdef0123456789abcdef"}"#
125 )
126 .is_err(),
127 "a client must not be able to name the content object"
128 );
129 }
130
131 #[test]
132 fn a_download_grant_exposes_only_presigned_access() {
133 let response = CreateDownloadResponse {
134 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
135 path: absolute_path(),
136 revision_no: RevisionNo(7),
137 content_ref: content_ref(),
138 access: ObjectTransferAccess::PresignedUrl {
139 method: "GET".to_owned(),
140 url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
141 headers: BTreeMap::new(),
142 expires_at_ms: 1,
143 },
144 };
145
146 assert_eq!(
147 serde_json::to_value(&response).expect("serialize response"),
148 serde_json::json!({
149 "namespace_id": "demo",
150 "path": "/docs/report.txt",
151 "revision_no": 7,
152 "content_ref": content_ref_json(),
153 "access": {
154 "kind": "presigned_url",
155 "method": "GET",
156 "url": "https://bucket.example/object?X-Amz-Signature=abc",
157 "expires_at_ms": 1
158 }
159 })
160 );
161 }
162
163 #[test]
164 fn an_inode_download_grant_is_path_free() {
165 let response = CreateDownloadByInodeResponse {
166 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
167 inode_id: crate::InodeId(42),
168 revision_no: RevisionNo(7),
169 content_ref: content_ref(),
170 access: ObjectTransferAccess::PresignedUrl {
171 method: "GET".to_owned(),
172 url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
173 headers: BTreeMap::new(),
174 expires_at_ms: 1,
175 },
176 };
177 assert_eq!(
178 serde_json::to_value(&response).expect("serialize response"),
179 serde_json::json!({
180 "namespace_id": "demo",
181 "inode_id": "ino_42",
182 "revision_no": 7,
183 "content_ref": content_ref_json(),
184 "access": {
185 "kind": "presigned_url",
186 "method": "GET",
187 "url": "https://bucket.example/object?X-Amz-Signature=abc",
188 "expires_at_ms": 1
189 }
190 })
191 );
192 }
193}