Skip to content

Commit a1c3a32

Browse files
breaking: change url format: {usecase}/{scope1}/.../data/{key} (#203)
### main points - changes objectstore's url format to: ``` {usecase}/{scope1}/.../objects/{key} ``` - updates both clients to use the new format any deployed uses of objectstore will break when we roll this out. we are pre-production so that feels acceptable as long as we make sure we turn off any feature flags we've got out there first until we deploy + update clients we are discussing renaming `scope` but i'll do that in a different PR ### extra detail axum's route syntax doesn't allow `{usecase}/{*scope}/objects/{*key}` for variable-length scopes because that sort of `*` wildcard is only allowed at the end of a path. to work around that, i wrote a custom `serde::Deserialize` implementation for `ObjectPath`. now a `/{*object_path}` route produces a `Path<ObjectPath>`. as a bonus, it lets us get rid of the mostly-duplicated `put_object_nokey` handler `ObjectPath` itself was changed in two ways: - `scope` is now a `Vec<String>` containing each scope component. this will make auth logic easier - i opted not to split each scope component on `.` and store each k/v pair in an `IndexMap` as i don't think we really need them separated. it just makes re-serializing the path more expensive. we can change this later though - the `Deserialize` implementation will handle generating a UUIDv4 if one wasn't found in the path Close FS-212 --------- Co-authored-by: Arpad Borsos <[email protected]>
1 parent 3f0b9db commit a1c3a32

File tree

13 files changed

+159
-89
lines changed

13 files changed

+159
-89
lines changed

clients/python/src/objectstore_client/client.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from dataclasses import asdict, dataclass
66
from io import BytesIO
77
from typing import IO, Any, Literal, NamedTuple, cast
8-
from urllib.parse import urlencode
98

109
import sentry_sdk
1110
import urllib3
@@ -207,12 +206,11 @@ def _make_headers(self) -> dict[str, str]:
207206
return {}
208207

209208
def _make_url(self, key: str | None, full: bool = False) -> str:
210-
base_path = f"/v1/{key}" if key else "/v1/"
211-
qs = urlencode({"usecase": self._usecase.name, "scope": self._scope})
209+
base_path = f"/v1/{self._usecase.name}/{self._scope}/objects/{key or ''}"
212210
if full:
213-
return f"http://{self._pool.host}:{self._pool.port}{base_path}?{qs}"
211+
return f"http://{self._pool.host}:{self._pool.port}{base_path}"
214212
else:
215-
return f"{base_path}?{qs}"
213+
return f"{base_path}"
216214

217215
def put(
218216
self,

clients/python/tests/test_smoke.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
from objectstore_client import Client, Usecase
2+
3+
14
def test_imports() -> None:
25
import objectstore_client # noqa: F401
36
from objectstore_client import client, metadata, metrics # noqa: F401
7+
8+
9+
def test_object_url() -> None:
10+
client = Client("http://127.0.0.1:8888/")
11+
session = client.session(
12+
Usecase("testing"), org=12345, project=1337, app_slug="email_app"
13+
)
14+
15+
assert (
16+
session.object_url("foo/bar")
17+
== "http://127.0.0.1:8888/v1/testing/org.12345/project.1337/app_slug.email_app/objects/foo/bar"
18+
)

clients/rust/src/client.rs

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use futures_util::stream::BoxStream;
77
use objectstore_types::ExpirationPolicy;
88
use url::Url;
99

10-
pub use objectstore_types::{Compression, PARAM_SCOPE, PARAM_USECASE};
10+
pub use objectstore_types::Compression;
1111

1212
const USER_AGENT: &str = concat!("objectstore-client/", env!("CARGO_PKG_VERSION"));
1313

@@ -258,7 +258,7 @@ impl Scope {
258258
}
259259

260260
/// Extends this Scope by creating a new sub-scope nested within it.
261-
pub fn push<V>(self, key: &str, value: &V) -> Self
261+
pub fn push<V>(self, key: &str, value: V) -> Self
262262
where
263263
V: std::fmt::Display,
264264
{
@@ -410,22 +410,29 @@ pub struct Session {
410410
pub type ClientStream = BoxStream<'static, io::Result<Bytes>>;
411411

412412
impl Session {
413+
/// Generates a GET url to the object with the given `key`.
414+
///
415+
/// This can then be used by downstream services to fetch the given object.
416+
/// NOTE however that the service does not strictly follow HTTP semantics,
417+
/// in particular in relation to `Accept-Encoding`.
418+
pub fn object_url(&self, object_key: &str) -> Url {
419+
let mut url = self.client.service_url.clone();
420+
let path = format!(
421+
"v1/{}/{}/objects/{object_key}",
422+
self.scope.usecase.name, self.scope.scope
423+
);
424+
url.set_path(&path);
425+
url
426+
}
427+
413428
pub(crate) fn request(
414429
&self,
415430
method: reqwest::Method,
416-
resource_id: &str,
417-
) -> crate::Result<reqwest::RequestBuilder> {
418-
let mut url = self.client.service_url.clone();
419-
url.path_segments_mut()
420-
.map_err(|_| crate::Error::InvalidUrl {
421-
message: format!("The URL {} cannot be a base", self.client.service_url),
422-
})?
423-
.extend(&["v1", resource_id]);
431+
object_key: &str,
432+
) -> reqwest::RequestBuilder {
433+
let url = self.object_url(object_key);
424434

425-
let mut builder = self.client.reqwest.request(method, url).query(&[
426-
(PARAM_SCOPE, self.scope.scope.as_str()),
427-
(PARAM_USECASE, self.scope.usecase.name.as_ref()),
428-
]);
435+
let mut builder = self.client.reqwest.request(method, url);
429436

430437
if self.client.propagate_traces {
431438
let trace_headers =
@@ -435,6 +442,26 @@ impl Session {
435442
}
436443
}
437444

438-
Ok(builder)
445+
builder
446+
}
447+
}
448+
449+
#[cfg(test)]
450+
mod tests {
451+
use super::*;
452+
453+
#[test]
454+
fn test_object_url() {
455+
let client = Client::new("http://127.0.0.1:8888/").unwrap();
456+
let usecase = Usecase::new("testing");
457+
let scope = usecase
458+
.for_project(12345, 1337)
459+
.push("app_slug", "email_app");
460+
let session = client.session(scope).unwrap();
461+
462+
assert_eq!(
463+
session.object_url("foo/bar").to_string(),
464+
"http://127.0.0.1:8888/v1/testing/org.12345/project.1337/app_slug.email_app/objects/foo/bar"
465+
)
439466
}
440467
}

clients/rust/src/delete.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ impl DeleteBuilder {
2424
/// Sends the `DELETE` request.
2525
pub async fn send(self) -> crate::Result<DeleteResponse> {
2626
self.session
27-
.request(reqwest::Method::DELETE, &self.key)?
27+
.request(reqwest::Method::DELETE, &self.key)
2828
.send()
2929
.await?;
3030
Ok(())

clients/rust/src/get.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ impl GetBuilder {
7777
pub async fn send(self) -> crate::Result<Option<GetResponse>> {
7878
let response = self
7979
.session
80-
.request(reqwest::Method::GET, &self.key)?
80+
.request(reqwest::Method::GET, &self.key)
8181
.send()
8282
.await?;
8383
if response.status() == StatusCode::NOT_FOUND {

clients/rust/src/put.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ impl PutBuilder {
141141
let mut builder = self.session.request(
142142
reqwest::Method::PUT,
143143
self.key.as_deref().unwrap_or_default(),
144-
)?;
144+
);
145145

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

objectstore-server/src/endpoints.rs

Lines changed: 7 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,22 @@ use std::io;
44

55
use anyhow::Context;
66
use axum::body::Body;
7-
use axum::extract::{Path, Query, State};
7+
use axum::extract::{Path, State};
88
use axum::http::{HeaderMap, StatusCode};
99
use axum::response::{IntoResponse, Response};
1010
use axum::routing::{get, put};
1111
use axum::{Json, Router};
1212
use futures_util::{StreamExt, TryStreamExt};
1313
use objectstore_service::ObjectPath;
1414
use objectstore_types::Metadata;
15-
use serde::{Deserialize, Serialize};
15+
use serde::Serialize;
1616

1717
use crate::error::ApiResult;
1818
use crate::state::ServiceState;
1919

2020
pub fn routes() -> Router<ServiceState> {
21-
let service_routes = Router::new().route("/", put(put_object_nokey)).route(
22-
"/{*key}",
21+
let service_routes = Router::new().route(
22+
"/{*path}",
2323
put(put_object).get(get_object).delete(delete_object),
2424
);
2525

@@ -32,52 +32,17 @@ async fn health() -> impl IntoResponse {
3232
"OK"
3333
}
3434

35-
#[derive(Deserialize, Debug)]
36-
struct ContextParams {
37-
scope: String,
38-
usecase: String,
39-
}
40-
4135
#[derive(Debug, Serialize)]
4236
struct PutBlobResponse {
4337
key: String,
4438
}
4539

46-
async fn put_object_nokey(
47-
State(state): State<ServiceState>,
48-
Query(params): Query<ContextParams>,
49-
headers: HeaderMap,
50-
body: Body,
51-
) -> ApiResult<impl IntoResponse> {
52-
let path = ObjectPath {
53-
usecase: params.usecase,
54-
scope: params.scope,
55-
key: uuid::Uuid::new_v4().to_string(),
56-
};
57-
populate_sentry_scope(&path);
58-
let metadata =
59-
Metadata::from_headers(&headers, "").context("extracting metadata from headers")?;
60-
61-
let stream = body.into_data_stream().map_err(io::Error::other).boxed();
62-
let key = state.service.put_object(path, &metadata, stream).await?;
63-
64-
Ok(Json(PutBlobResponse {
65-
key: key.key.to_string(),
66-
}))
67-
}
68-
6940
async fn put_object(
7041
State(state): State<ServiceState>,
71-
Query(params): Query<ContextParams>,
72-
Path(key): Path<String>,
42+
Path(path): Path<ObjectPath>,
7343
headers: HeaderMap,
7444
body: Body,
7545
) -> ApiResult<impl IntoResponse> {
76-
let path = ObjectPath {
77-
usecase: params.usecase,
78-
scope: params.scope,
79-
key,
80-
};
8146
populate_sentry_scope(&path);
8247
let metadata =
8348
Metadata::from_headers(&headers, "").context("extracting metadata from headers")?;
@@ -92,16 +57,9 @@ async fn put_object(
9257

9358
async fn get_object(
9459
State(state): State<ServiceState>,
95-
Query(params): Query<ContextParams>,
96-
Path(key): Path<String>,
60+
Path(path): Path<ObjectPath>,
9761
) -> ApiResult<Response> {
98-
let path = ObjectPath {
99-
usecase: params.usecase,
100-
scope: params.scope,
101-
key,
102-
};
10362
populate_sentry_scope(&path);
104-
10563
let Some((metadata, stream)) = state.service.get_object(&path).await? else {
10664
return Ok(StatusCode::NOT_FOUND.into_response());
10765
};
@@ -114,14 +72,8 @@ async fn get_object(
11472

11573
async fn delete_object(
11674
State(state): State<ServiceState>,
117-
Query(params): Query<ContextParams>,
118-
Path(key): Path<String>,
75+
Path(path): Path<ObjectPath>,
11976
) -> ApiResult<impl IntoResponse> {
120-
let path = ObjectPath {
121-
usecase: params.usecase,
122-
scope: params.scope,
123-
key,
124-
};
12577
populate_sentry_scope(&path);
12678

12779
state.service.delete_object(&path).await?;

objectstore-service/src/backend/bigtable.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ mod tests {
359359
fn make_key() -> ObjectPath {
360360
ObjectPath {
361361
usecase: "testing".into(),
362-
scope: "testing".into(),
362+
scope: vec!["testing".into()],
363363
key: Uuid::new_v4().to_string(),
364364
}
365365
}

objectstore-service/src/backend/gcs.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ mod tests {
450450
fn make_key() -> ObjectPath {
451451
ObjectPath {
452452
usecase: "testing".into(),
453-
scope: "testing".into(),
453+
scope: vec!["testing".into()],
454454
key: Uuid::new_v4().to_string(),
455455
}
456456
}

objectstore-service/src/backend/local_fs.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ mod tests {
122122

123123
let key = ObjectPath {
124124
usecase: "testing".into(),
125-
scope: "testing".into(),
125+
scope: vec!["testing".into()],
126126
key: "testing".into(),
127127
};
128128
let metadata = Metadata {

0 commit comments

Comments
 (0)