Skip to content

Commit 96c9f32

Browse files
authored
ref(server): Use "correct" methods and status codes for creation (#215)
Creating an object with or without a key usually uses different HTTP verbs. PUT writes or overrides an object at a known key, while POST always adds a new object with a server-defined key. The status codes also differ, with POST returning 201 CREATED. If we think this is useful, we could also make PUT return 201, but for that we'd have to return from the backend whether the object previously existed. Internally, we now have two types: - `ObjectPath` requires a key to be present. It is used for GET and DELETE. - `OptionalObjectPath` has an optional key. It can be converted into `ObjectPath`, either requiring or creating a random key. ObjectPath deserializes via OptionalObjectPath.
1 parent 5c88aeb commit 96c9f32

File tree

6 files changed

+163
-46
lines changed

6 files changed

+163
-46
lines changed

clients/python/src/objectstore_client/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,14 @@ def put(
257257
for k, v in metadata.items():
258258
headers[f"{HEADER_META_PREFIX}{k}"] = v
259259

260+
if key == "":
261+
key = None
262+
260263
with measure_storage_operation(
261264
self._metrics_backend, "put", self._usecase.name
262265
) as metric_emitter:
263266
response = self._pool.request(
264-
"PUT",
267+
"POST" if not key else "PUT",
265268
self._make_url(key),
266269
body=body,
267270
headers=headers,

clients/python/tests/test_e2e.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,18 @@ def test_full_cycle(server_url: str) -> None:
103103

104104
session = client.session(test_usecase, org=42, project=1337)
105105

106-
data = b"test data"
107-
108-
object_key = session.put(data)
106+
object_key = session.put(b"test data")
109107
assert object_key is not None
110108

111109
retrieved = session.get(object_key)
112-
assert retrieved.payload.read() == data
110+
assert retrieved.payload.read() == b"test data"
113111
assert retrieved.metadata.time_created is not None
114112

113+
new_key = session.put(b"new data", key=object_key)
114+
assert new_key == object_key
115+
retrieved = session.get(object_key)
116+
assert retrieved.payload.read() == b"new data"
117+
115118
session.delete(object_key)
116119

117120
with pytest.raises(RequestError, check=lambda e: e.status == 404):
@@ -168,4 +171,4 @@ def test_connect_timeout() -> None:
168171
)
169172

170173
with pytest.raises(urllib3.exceptions.MaxRetryError):
171-
session.put(b"foo")
174+
session.get("foo")

clients/rust/src/put.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ impl PutBuilder {
8484
/// If a key is specified, the object will be stored under that key. Otherwise, the Objectstore
8585
/// server will automatically assign a random key, which is then returned from this request.
8686
pub fn key(mut self, key: impl Into<String>) -> Self {
87-
self.key = Some(key.into());
87+
self.key = Some(key.into()).filter(|k| !k.is_empty());
8888
self
8989
}
9090

@@ -138,10 +138,14 @@ impl PutBuilder {
138138
impl PutBuilder {
139139
/// Sends the built PUT request to the upstream service.
140140
pub async fn send(self) -> crate::Result<PutResponse> {
141-
let mut builder = self.session.request(
142-
reqwest::Method::PUT,
143-
self.key.as_deref().unwrap_or_default(),
144-
);
141+
let method = match self.key {
142+
Some(_) => reqwest::Method::PUT,
143+
None => reqwest::Method::POST,
144+
};
145+
146+
let mut builder = self
147+
.session
148+
.request(method, self.key.as_deref().unwrap_or_default());
145149

146150
let body = match (self.metadata.compression, self.body) {
147151
(Some(Compression::Zstd), PutBody::Buffer(bytes)) => {

clients/rust/src/tests.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,30 @@ async fn stores_under_given_key() {
107107
assert_eq!(stored_id, "test-key123!!");
108108
}
109109

110+
#[tokio::test]
111+
async fn overwrites_existing_key() {
112+
let server = TestServer::new().await;
113+
114+
let client = ClientBuilder::new(server.url("/")).build().unwrap();
115+
let usecase = Usecase::new("usecase");
116+
let session = client.session(usecase.for_project(12345, 1337)).unwrap();
117+
118+
let stored_id = session.put("initial body").send().await.unwrap().key;
119+
let overwritten_id = session
120+
.put("new body")
121+
.key(&stored_id)
122+
.send()
123+
.await
124+
.unwrap()
125+
.key;
126+
127+
assert_eq!(stored_id, overwritten_id);
128+
129+
let response = session.get(&stored_id).send().await.unwrap().unwrap();
130+
let payload = response.payload().await.unwrap();
131+
assert_eq!(payload, "new body");
132+
}
133+
110134
#[derive(Debug)]
111135
pub struct TestServer {
112136
handle: tokio::task::JoinHandle<()>,

objectstore-server/src/endpoints.rs

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ use std::time::SystemTime;
66
use anyhow::Context;
77
use axum::body::Body;
88
use axum::extract::{Path, State};
9-
use axum::http::{HeaderMap, StatusCode};
9+
use axum::http::{HeaderMap, Method, StatusCode};
1010
use axum::response::{IntoResponse, Response};
11-
use axum::routing::{get, put};
11+
use axum::routing;
1212
use axum::{Json, Router};
1313
use futures_util::{StreamExt, TryStreamExt};
14-
use objectstore_service::ObjectPath;
14+
use objectstore_service::{ObjectPath, OptionalObjectPath};
1515
use objectstore_types::Metadata;
1616
use serde::Serialize;
1717

@@ -21,11 +21,14 @@ use crate::state::ServiceState;
2121
pub fn routes() -> Router<ServiceState> {
2222
let service_routes = Router::new().route(
2323
"/{*path}",
24-
put(put_object).get(get_object).delete(delete_object),
24+
routing::post(insert_object)
25+
.put(insert_object)
26+
.get(get_object)
27+
.delete(delete_object),
2528
);
2629

2730
Router::new()
28-
.route("/health", get(health))
31+
.route("/health", routing::get(health))
2932
.nest("/v1/", service_routes)
3033
}
3134

@@ -38,24 +41,37 @@ struct PutBlobResponse {
3841
key: String,
3942
}
4043

41-
async fn put_object(
44+
async fn insert_object(
4245
State(state): State<ServiceState>,
43-
Path(path): Path<ObjectPath>,
46+
Path(path): Path<OptionalObjectPath>,
47+
method: Method,
4448
headers: HeaderMap,
4549
body: Body,
46-
) -> ApiResult<impl IntoResponse> {
50+
) -> ApiResult<Response> {
51+
let (expected_method, response_status) = match path.key {
52+
Some(_) => (Method::PUT, StatusCode::OK),
53+
None => (Method::POST, StatusCode::CREATED),
54+
};
55+
56+
// TODO: For now allow PUT everywhere. Remove the second condition when all clients are updated.
57+
if method != expected_method && method == Method::POST {
58+
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
59+
}
60+
61+
let path = path.create_key();
4762
populate_sentry_scope(&path);
4863

4964
let mut metadata =
5065
Metadata::from_headers(&headers, "").context("extracting metadata from headers")?;
5166
metadata.time_created = Some(SystemTime::now());
5267

5368
let stream = body.into_data_stream().map_err(io::Error::other).boxed();
54-
let key = state.service.put_object(path, &metadata, stream).await?;
69+
let response_path = state.service.put_object(path, &metadata, stream).await?;
70+
let response = Json(PutBlobResponse {
71+
key: response_path.key.to_string(),
72+
});
5573

56-
Ok(Json(PutBlobResponse {
57-
key: key.key.to_string(),
58-
}))
74+
Ok((response_status, response).into_response())
5975
}
6076

6177
async fn get_object(

objectstore-service/src/path.rs

Lines changed: 90 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,66 @@ use serde::de;
55
/// Magic URL segment that separates objectstore context from an object's user-provided key.
66
const PATH_CONTEXT_SEPARATOR: &str = "objects";
77

8-
/// The fully scoped path of an object.
9-
///
10-
/// This consists of a usecase, the scope, and the user-defined object key.
8+
/// An [`ObjectPath`] that may or may not have a user-provided key.
9+
// DO NOT derive Eq, see the implementation of PartialEq below.
1110
#[derive(Debug, Clone)]
12-
pub struct ObjectPath {
11+
pub struct OptionalObjectPath {
1312
/// The usecase, or "product" this object belongs to.
14-
///
15-
/// This can be defined on-the-fly by the client, but special server logic
16-
/// (such as the concrete backend/bucket) can be tied to this as well.
1713
pub usecase: String,
18-
1914
/// The scope of the object, used for compartmentalization.
20-
///
21-
/// This is treated as a prefix, and includes such things as the organization and project.
2215
pub scope: Vec<String>,
16+
/// The optional, user-provided key.
17+
pub key: Option<String>,
18+
}
2319

24-
/// This is a user-defined key, which uniquely identifies the object within its usecase/scope.
25-
pub key: String,
20+
impl OptionalObjectPath {
21+
/// Converts to an [`ObjectPath`], generating a unique `key` if none was provided.
22+
pub fn create_key(self) -> ObjectPath {
23+
ObjectPath {
24+
usecase: self.usecase,
25+
scope: self.scope,
26+
key: self.key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
27+
}
28+
}
29+
30+
/// Converts to an [`ObjectPath`], returning an error if no `key` was provided.
31+
pub fn require_key(self) -> Result<ObjectPath, &'static str> {
32+
Ok(ObjectPath {
33+
usecase: self.usecase,
34+
scope: self.scope,
35+
key: self
36+
.key
37+
.ok_or("object key is required but was not provided")?,
38+
})
39+
}
2640
}
2741

28-
impl Display for ObjectPath {
42+
impl Display for OptionalObjectPath {
2943
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3044
write!(f, "{}/", self.usecase)?;
3145
for scope in &self.scope {
3246
write!(f, "{}/", scope)?;
3347
}
34-
write!(f, "{PATH_CONTEXT_SEPARATOR}/{}", self.key)
48+
write!(f, "{PATH_CONTEXT_SEPARATOR}/")?;
49+
if let Some(ref key) = self.key {
50+
f.write_str(key)?;
51+
}
52+
Ok(())
53+
}
54+
}
55+
56+
impl PartialEq for OptionalObjectPath {
57+
fn eq(&self, other: &Self) -> bool {
58+
self.usecase == other.usecase
59+
&& self.scope == other.scope
60+
&& self.key == other.key
61+
&& self.key.is_some() // Two paths without keys are considered unequal!
3562
}
3663
}
3764

38-
struct ObjectPathVisitor;
39-
impl<'de> serde::de::Visitor<'de> for ObjectPathVisitor {
40-
type Value = ObjectPath;
65+
struct OptionalObjectPathVisitor;
66+
impl<'de> serde::de::Visitor<'de> for OptionalObjectPathVisitor {
67+
type Value = OptionalObjectPath;
4168

4269
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
4370
write!(
@@ -86,25 +113,65 @@ impl<'de> serde::de::Visitor<'de> for ObjectPathVisitor {
86113
}
87114

88115
// The rest of the path is a user-provided key.
89-
// If no key is provided, generate a UUIDv4 by default.
90-
let key = match iter.peek() {
91-
None => uuid::Uuid::new_v4().to_string(),
92-
Some(_) => iter.collect(),
116+
let key = if iter.peek().is_some() {
117+
Some(iter.collect())
118+
} else {
119+
None
93120
};
94121

95-
Ok(ObjectPath {
122+
Ok(OptionalObjectPath {
96123
usecase,
97124
scope,
98125
key,
99126
})
100127
}
101128
}
102129

130+
impl<'de> de::Deserialize<'de> for OptionalObjectPath {
131+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
132+
where
133+
D: de::Deserializer<'de>,
134+
{
135+
deserializer.deserialize_str(OptionalObjectPathVisitor)
136+
}
137+
}
138+
139+
/// The fully scoped path of an object.
140+
///
141+
/// This consists of a usecase, the scope, and the object key.
142+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
143+
pub struct ObjectPath {
144+
/// The usecase, or "product" this object belongs to.
145+
///
146+
/// This can be defined on-the-fly by the client, but special server logic
147+
/// (such as the concrete backend/bucket) can be tied to this as well.
148+
pub usecase: String,
149+
150+
/// The scope of the object, used for compartmentalization.
151+
///
152+
/// This is treated as a prefix, and includes such things as the organization and project.
153+
pub scope: Vec<String>,
154+
155+
/// This key uniquely identifies the object within its usecase/scope.
156+
pub key: String,
157+
}
158+
159+
impl Display for ObjectPath {
160+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161+
write!(f, "{}/", self.usecase)?;
162+
for scope in &self.scope {
163+
write!(f, "{}/", scope)?;
164+
}
165+
write!(f, "{PATH_CONTEXT_SEPARATOR}/{}", self.key)
166+
}
167+
}
168+
103169
impl<'de> de::Deserialize<'de> for ObjectPath {
104170
fn deserialize<D>(deserializer: D) -> Result<ObjectPath, D::Error>
105171
where
106172
D: de::Deserializer<'de>,
107173
{
108-
deserializer.deserialize_str(ObjectPathVisitor)
174+
let optional_path = OptionalObjectPath::deserialize(deserializer)?;
175+
optional_path.require_key().map_err(de::Error::custom)
109176
}
110177
}

0 commit comments

Comments
 (0)