Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ cargo test # runs all non-ignored tests
cargo test <name> # substring match on test name
cargo test -- --ignored # run tests marked #[ignore] (e.g. long-running datapoint tests)
cargo test <path>::tests::<name> # e.g. `events::tests::test_events_full`
cargo test -- --nocapture # show println! from tests (the SDK prints response bodies)
cargo test -- --nocapture # show println! from tests
cargo test --release <bench name> # ALWAYS --release for anything timed (see below)
./run_python_tests.sh # Python-bindings suite (rebuilds the PyO3 module first — see below)
```
Expand Down Expand Up @@ -206,7 +206,7 @@ Every subservice implements `ApiServiceProvider`, which owns the HTTP plumbing:

### Response shape: `DataWrapper<T>`

The API wraps collections in `{ "items": [...] }`. `DataWrapper<T>` mirrors that and carries the HTTP status code + raw error body alongside items. Deserialization goes through the `DataWrapperDeserialization` trait, which tolerates 204/empty bodies and stores non-2xx bodies in `error_body` instead of failing. When adding new endpoint methods, return `Result<DataWrapper<T>, ResponseError>`.
The API wraps collections in `{ "items": [...] }`. `DataWrapper<T>` mirrors that and carries the HTTP status code alongside items. Deserialization goes through the `DataWrapperDeserialization` trait, which tolerates 204/empty bodies; it only ever sees 2xx responses, since `process_response` turns everything else into a `ResponseError`. When adding new endpoint methods, return `Result<DataWrapper<T>, ResponseError>`.

### Entity → request-body conversion

Expand Down Expand Up @@ -567,5 +567,5 @@ under another test, and use a fixed *pair* when a test has to tell two labels ap
- `#[serde(rename = "camelCase")]` or explicit `#[serde(rename = "...")]` on fields — the backend is camelCase, Rust is snake_case.
- **A request body naming a field the api does not have is a 400.** Jackson used to drop unknown properties, so a stale or misspelled key was answered with 200 and no effect; a strict converter now rejects the body and names every offender alongside the fields the endpoint accepts. Two consequences for this SDK: a struct that doubles as request *and* response must `#[serde(skip_serializing)]` its response-only fields — `GraphDataWrapper`'s `errorBody`/`httpStatusCode` reached `/resources/create` and made every resource and function create and update a 400 — and one Rust type may not stand in for two endpoints that disagree on their fields (see the search forms above). Reading is unaffected: responses stay lenient in both directions.
- `externalId` (string, user-supplied) and numeric `id` are both valid identifiers across the API. `IdAndExtId` / `IdAndExtIdCollection` model this choice.
- `process_response` (`src/http.rs`) prints response bodies to stdout (truncated to 2000 chars). This is deliberate for debugging — don't silently remove it.
- The SDK does not print. Everything a caller could want to see is in the returned value or the `ResponseError`, and a library writing to stdout or stderr cannot be silenced by the application embedding it.
- Tests that depend on backend state being empty are brittle; recent fixes moved away from exact-count assertions (see commit `7f0a059`). Don't add new ones.
32 changes: 10 additions & 22 deletions src/files/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,10 @@ impl FileService {
let file_name = filename_from_content_disposition(&response);
let mime_type = header_value(&response, reqwest::header::CONTENT_TYPE);
let status = response.status();
let bytes = response.bytes().await.map_err(|err| {
eprintln!("Failed to read download body: {}", err);
ResponseError {
status,
message: err.to_string(),
content_type: None,
}
let bytes = response.bytes().await.map_err(|err| ResponseError {
status,
message: err.to_string(),
content_type: None,
})?;

Ok(FileDownload {
Expand Down Expand Up @@ -194,13 +191,10 @@ impl FileService {

let mut file = File::create(destination.as_ref()).await.map_err(io_error)?;
let mut written: u64 = 0;
while let Some(chunk) = response.chunk().await.map_err(|err| {
eprintln!("Download stream failed: {}", err);
ResponseError {
status,
message: err.to_string(),
content_type: None,
}
while let Some(chunk) = response.chunk().await.map_err(|err| ResponseError {
status,
message: err.to_string(),
content_type: None,
})? {
file.write_all(&chunk).await.map_err(io_error)?;
written += chunk.len() as u64;
Expand Down Expand Up @@ -424,14 +418,8 @@ impl FileUpload {

let kind: Option<String> = match infer::get_from_path(file_path) {
Ok(Some(file_type)) => Some(file_type.mime_type().to_string()),
Ok(None) => {
println!("Could not determine file type for: {}", file_path);
Some("application/octet-stream".to_string())
}
Err(e) => {
eprintln!("Error detecting file type for {}: {}", file_path, e);
None
}
Ok(None) => Some("application/octet-stream".to_string()),
Err(_) => None,
};

Ok(Self {
Expand Down
103 changes: 23 additions & 80 deletions src/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -743,23 +743,17 @@ pub trait ApiServiceProvider {
.query(param)
.send()
.await
.map_err(|err| {
eprintln!("HTTP request failed: {}", err);
ResponseError::from_err(err)
})?
.map_err(ResponseError::from_err)?
} else {
self.get_api_service()
.http_client
.get(path)
.bearer_auth(token.clone())
.send()
.await
.map_err(|err| {
eprintln!("HTTP request failed: {}", err);
ResponseError::from_err(err)
})?
.map_err(ResponseError::from_err)?
};
match process_response::<T>(response, path).await {
match process_response::<T>(response).await {
Ok(value) => Ok(value),
Err(e) => Err(self.on_request_error(e, &token).await),
}
Expand All @@ -782,22 +776,18 @@ pub trait ApiServiceProvider {
.bearer_auth(token.clone())
.send()
.await
.map_err(|err| {
eprintln!("HTTP request failed: {}", err);
ResponseError::from_err(err)
})?;
.map_err(ResponseError::from_err)?;
if response.status() == 204 {
// Return deserialized `T` with an empty body and the HTTP status code
T::deserialize_and_set_status("", response.status().as_u16()).map_err(|err| {
eprintln!("Failed to create object from empty response: {}", err);
ResponseError {
status: response.status(),
message: err.to_string(),
content_type: None,
}
})
} else {
match process_response::<T>(response, path).await {
match process_response::<T>(response).await {
Ok(value) => Ok(value),
Err(e) => Err(self.on_request_error(e, &token).await),
}
Expand All @@ -820,11 +810,8 @@ pub trait ApiServiceProvider {
.bearer_auth(token.clone())
.send()
.await
.map_err(|err| {
eprintln!("HTTP request failed: {}", err);
ResponseError::from_err(err)
})?;
match process_response::<T>(response, path).await {
.map_err(ResponseError::from_err)?;
match process_response::<T>(response).await {
Ok(value) => Ok(value),
Err(e) => Err(self.on_request_error(e, &token).await),
}
Expand Down Expand Up @@ -852,11 +839,8 @@ pub trait ApiServiceProvider {
request = request.header(name, value);
}

let response = request.send().await.map_err(|err| {
eprintln!("HTTP file upload request failed: {}", err);
ResponseError::from_err(err)
})?;
match process_response::<T>(response, path).await {
let response = request.send().await.map_err(ResponseError::from_err)?;
match process_response::<T>(response).await {
Ok(value) => Ok(value),
Err(e) => Err(self.on_request_error(e, &token).await),
}
Expand All @@ -882,10 +866,7 @@ pub trait ApiServiceProvider {
.bearer_auth(token.clone())
.send()
.await
.map_err(|err| {
eprintln!("HTTP request failed: {}", err);
ResponseError::from_err(err)
})?;
.map_err(ResponseError::from_err)?;
if response.status() == 204 {
return T::deserialize_and_set_status("", response.status().as_u16()).map_err(|err| {
ResponseError {
Expand All @@ -895,7 +876,7 @@ pub trait ApiServiceProvider {
}
});
}
match process_response::<T>(response, path).await {
match process_response::<T>(response).await {
Ok(value) => Ok(value),
Err(e) => Err(self.on_request_error(e, &token).await),
}
Expand All @@ -904,10 +885,9 @@ pub trait ApiServiceProvider {
/// `GET` an endpoint that answers with bytes rather than JSON (currently only
/// `/files/download/{id}`).
///
/// The body is *not* passed through [`process_response`] — there is no `DataWrapper` to
/// deserialize and no reason to print a binary body to stdout. Non-2xx responses still surface
/// as a [`ResponseError`] carrying the server's text explanation, so error handling matches the
/// JSON helpers.
/// The body is *not* passed through `process_response` — there is no `DataWrapper` to
/// deserialize. Non-2xx responses still surface as a [`ResponseError`] carrying the server's
/// text explanation, so error handling matches the JSON helpers.
async fn execute_get_stream_request(
&self,
path: &str,
Expand All @@ -921,10 +901,7 @@ pub trait ApiServiceProvider {
.header(http::header::ACCEPT, "*/*")
.send()
.await
.map_err(|err| {
eprintln!("HTTP request failed: {}", err);
ResponseError::from_err(err)
})?;
.map_err(ResponseError::from_err)?;

let status = response.status();
if status.is_success() {
Expand All @@ -937,7 +914,6 @@ pub trait ApiServiceProvider {
if status == http::StatusCode::UNAUTHORIZED {
self.get_api_service().config.invalidate_token().await;
}
eprintln!("Request failed with status: {status}");
// Read the header before the body: `text()` consumes the response.
let content_type = response
.headers()
Expand Down Expand Up @@ -1045,48 +1021,15 @@ where
DataWrapper<T>: Sized,
{
fn deserialize_and_set_status(body: &str, status_code: u16) -> Result<Self, serde_json::Error> {
if status_code >= 200 && status_code < 300 {
if status_code == 204 || body.is_empty() {
// HTTP No content doesnt return anything
let mut wrapper: DataWrapper<T> = DataWrapper::new();
wrapper.set_http_status_code(status_code);
return Ok(wrapper);
}
// For 2xx responses, we expect the body to be a valid DataWrapper<T>
// If body is empty, it's fine for `from_str` to fail and return an error
// Or, if you specifically want an empty wrapper for 2xx with empty body:
// let mut wrapper = DataWrapper::new();
// wrapper.set_http_status_code(status_code);
// return Ok(wrapper);
// However, typically a successful response with a body should be parsed.
serde_json::from_str(body).map(|mut wrapper: DataWrapper<T>| {
wrapper.set_http_status_code(status_code);
wrapper
})
} else {
// For non-2xx responses (errors)
eprintln!(
"HTTP request failed with status code {}: {}",
status_code, body
);

// Attempt to deserialize the body into DataWrapper<T>
// This is useful if the error response *itself* is a structured JSON,
// for example, containing an error object.
match serde_json::from_str(body).map(|mut wrapper: DataWrapper<T>| {
wrapper.set_http_status_code(status_code); // Set the HTTP status code
wrapper // Return the modified wrapper
}) {
Ok(result) => Ok(result),
Err(_) => {
eprintln!("Error parsing HTTP response body: {}", body);
let mut wrapper: DataWrapper<T> = DataWrapper::new();
wrapper.error_body = Some(body.to_string());
wrapper.set_http_status_code(status_code);
Ok(wrapper)
}
}
if status_code == 204 || body.is_empty() {
let mut wrapper: DataWrapper<T> = DataWrapper::new();
wrapper.set_http_status_code(status_code);
return Ok(wrapper);
}
serde_json::from_str(body).map(|mut wrapper: DataWrapper<T>| {
wrapper.set_http_status_code(status_code);
wrapper
})
}
}

Expand Down
44 changes: 11 additions & 33 deletions src/graph_data_wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,40 +67,18 @@ impl<T: GraphNode + DeserializeOwned, R: DeserializeOwned> DataWrapperDeserializ
for GraphDataWrapper<T, R>
{
fn deserialize_and_set_status(body: &str, status_code: u16) -> Result<Self, serde_json::Error> {
if status_code >= 200 && status_code < 300 {
if status_code == 204 || body.is_empty() {
return Ok(Self {
nodes: None,
relations: None,
error_body: None,
http_status_code: Some(status_code),
});
}
serde_json::from_str(body).map(|mut wrapper: GraphDataWrapper<T, R>| {
wrapper.set_http_status_code(status_code);
wrapper
})
} else {
eprintln!(
"HTTP request failed with status code {}: {}",
status_code, body
);
match serde_json::from_str(body).map(|mut wrapper: GraphDataWrapper<T, R>| {
wrapper.set_http_status_code(status_code);
wrapper
}) {
Ok(result) => Ok(result),
Err(_) => {
eprintln!("Error parsing HTTP response body: {}", body);
Ok(GraphDataWrapper {
nodes: None,
relations: None,
error_body: Some(body.to_string()),
http_status_code: Some(status_code),
})
}
}
if status_code == 204 || body.is_empty() {
return Ok(Self {
nodes: None,
relations: None,
error_body: None,
http_status_code: Some(status_code),
});
}
serde_json::from_str(body).map(|mut wrapper: GraphDataWrapper<T, R>| {
wrapper.set_http_status_code(status_code);
wrapper
})
}
}

Expand Down
37 changes: 10 additions & 27 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,40 +136,23 @@ impl fmt::Display for ResponseError {
}
}

pub async fn process_response<T>(response: Response, path: &str) -> Result<T, ResponseError>
pub(crate) async fn process_response<T>(response: Response) -> Result<T, ResponseError>
where
T: DeserializeOwned + DataWrapperDeserialization,
{
let status = response.status();
if (200..300).contains(&status.as_u16()) {
// Read the response body and attempt to deserialize
let body = response.text().await.map_err(|err| {
eprintln!("Failed to read response body: {err}",);
ResponseError {
status,
message: err.to_string(),
content_type: None,
}
})?;

let max_chars = 2000;
let truncated_body = &body[..body.len().min(max_chars)];
println!("Response body for path: {}\n{}", path, &truncated_body); // Debug output

// Conditionally apply custom or default logic
let result: T = T::deserialize_and_set_status(&body, status.as_u16()).map_err(|err| {
eprintln!("Failed to deserialize JSON: {err}",);
ResponseError {
status,
message: err.to_string(),
content_type: None,
}
let body = response.text().await.map_err(|err| ResponseError {
status,
message: err.to_string(),
content_type: None,
})?;

Ok(result)
T::deserialize_and_set_status(&body, status.as_u16()).map_err(|err| ResponseError {
status,
message: err.to_string(),
content_type: None,
})
} else {
let status = response.status();
eprintln!("Request failed with status: {status}",);
// Read the header before the body: `text()` consumes the response.
let content_type = response
.headers()
Expand Down
2 changes: 1 addition & 1 deletion src/resources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::generic::{
};
use crate::graph_data_wrapper::{GraphDataWrapper, GraphNode};
use crate::nodes::Node;
use crate::http::{process_response, ResponseError};
use crate::http::ResponseError;
use crate::relations::{EdgeProxy, RelForm, RelatedNode};
use crate::ApiService;
use chrono::{DateTime, Utc};
Expand Down
Loading
Loading