Skip to content

Commit 96583d4

Browse files
feat(client): add first Client implementation adhering to the "Essentials" compliance criteria (#6)
* feat(client): add first Client implementation adhering to the "Essentials" compliance criteria Signed-off-by: Raphael Höser <[email protected]> * fix(Client): Add json feature to reqwest Signed-off-by: Raphael Höser <[email protected]> * fix(client): restructure client requests to use traits instead of enums for optimized stack usage --------- Signed-off-by: Raphael Höser <[email protected]>
1 parent 48fd60f commit 96583d4

File tree

12 files changed

+508
-40
lines changed

12 files changed

+508
-40
lines changed

Cargo.lock

Lines changed: 83 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@ edition = "2024"
55

66
[features]
77
default = []
8+
cloudevents = ["dep:cloudevents-sdk"]
89
testcontainer = ["dep:testcontainers"]
910

1011
[dependencies]
11-
thiserror = "2.0.12"
12+
chrono = { version = "0.4.41", features = ["serde"] }
13+
cloudevents-sdk = { version = "0.8.0", features = ["reqwest"], optional = true }
1214
url = "2.5.4"
13-
testcontainers = { version = "0.24.0", features = ["http_wait"], optional = true }
15+
reqwest = { version = "0.12.15", features = ["json"] }
16+
serde = { version = "1.0.219", features = ["derive"] }
17+
serde_json = "1.0.140"
18+
testcontainers = { version = "0.24.0", features = [
19+
"http_wait",
20+
], optional = true }
21+
thiserror = "2.0.12"
1422

1523
[dev-dependencies]
16-
eventsourcingdb-client-rust = {path = ".", features = ["testcontainer"]}
17-
reqwest = "0.12.15"
24+
eventsourcingdb-client-rust = { path = ".", features = ["testcontainer"] }
1825
testcontainers = { version = "0.24.0", features = ["http_wait"] }
1926
tokio = { version = "1.44.2", features = ["full"] }
2027
tokio-test = "0.4.4"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The official Rust client SDK for [EventSourcingDB](https://www.eventsourcingdb.i
77
This is a work in progress and not yet ready for production use.
88
Based on the [compliance criteria](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/) the SDK covers these criteria:
99

10-
- [Essentials](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#essentials)
10+
- 🚀 [Essentials](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#essentials)
1111
-[Writing Events](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#writing-events)
1212
-[Reading Events](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#reading-events)
1313
-[Using EventQL](https://docs.eventsourcingdb.io/client-sdks/compliance-criteria/#using-eventql)

src/client.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
//! Client for the [EventsourcingDB](https://www.eventsourcingdb.io/) API.
2+
//!
3+
//! To use the client, create it with the base URL and API token of your [EventsourcingDB](https://www.eventsourcingdb.io/) instance.
4+
//! ```
5+
//! # tokio_test::block_on(async {
6+
//! # let container = eventsourcingdb_client_rust::container::Container::start_default().await.unwrap();
7+
//! let db_url = "http://localhost:3000/";
8+
//! let api_token = "secrettoken";
9+
//! # let db_url = container.get_base_url().await.unwrap();
10+
//! # let api_token = container.get_api_token();
11+
//! let client = eventsourcingdb_client_rust::client::Client::new(db_url, api_token);
12+
//! client.ping().await.expect("Failed to ping");
13+
//! client.verify_api_token().await.expect("Failed to verify API token");
14+
//! # })
15+
//! ```
16+
//!
17+
//! With the code above you can verify that the DB is reachable and that the API token is valid.
18+
//! If this works, it means that the client is correctly configured and you can use it to make requests to the DB.
19+
20+
mod client_request;
21+
22+
use client_request::{ClientRequest, PingRequest, VerifyApiTokenRequest};
23+
24+
use reqwest;
25+
use url::Url;
26+
27+
use crate::error::ClientError;
28+
29+
/// Client for an [EventsourcingDB](https://www.eventsourcingdb.io/) instance.
30+
#[derive(Debug)]
31+
pub struct Client {
32+
base_url: Url,
33+
api_token: String,
34+
client: reqwest::Client,
35+
}
36+
37+
impl Client {
38+
/// Creates a new client instance based on the base URL and API token
39+
pub fn new(base_url: Url, api_token: impl Into<String>) -> Self {
40+
Client {
41+
base_url,
42+
api_token: api_token.into(),
43+
client: reqwest::Client::new(),
44+
}
45+
}
46+
47+
/// Get the base URL of the client to use for API calls
48+
/// ```
49+
/// # use url::Url;
50+
/// # use eventsourcingdb_client_rust::client::Client;
51+
/// # let client = Client::new("http://localhost:8080/".parse().unwrap(), "token");
52+
/// let base_url = client.get_base_url();
53+
/// # assert_eq!(base_url.as_str(), "http://localhost:8080/");
54+
/// ```
55+
#[must_use]
56+
pub fn get_base_url(&self) -> &Url {
57+
&self.base_url
58+
}
59+
60+
/// Get the API token of the client to use for API calls
61+
/// ```
62+
/// # use eventsourcingdb_client_rust::client::Client;
63+
/// # use url::Url;
64+
/// # let client = Client::new("http://localhost:8080/".parse().unwrap(), "secrettoken");
65+
/// let api_token = client.get_api_token();
66+
/// # assert_eq!(api_token, "secrettoken");
67+
/// ```
68+
#[must_use]
69+
pub fn get_api_token(&self) -> &str {
70+
&self.api_token
71+
}
72+
73+
/// Utility function to request an endpoint of the API.
74+
///
75+
/// # Errors
76+
/// This function will return an error if the request fails or if the URL is invalid.
77+
async fn request<R: ClientRequest>(&self, endpoint: R) -> Result<R::Response, ClientError> {
78+
let url = self
79+
.base_url
80+
.join(endpoint.url_path())
81+
.map_err(ClientError::URLParseError)?;
82+
83+
let request = match endpoint.method() {
84+
reqwest::Method::GET => self.client.get(url),
85+
reqwest::Method::POST => self.client.post(url),
86+
_ => return Err(ClientError::InvalidRequestMethod),
87+
}
88+
.header("Authorization", format!("Bearer {}", self.api_token));
89+
let request = if let Some(body) = endpoint.body() {
90+
request
91+
.header("Content-Type", "application/json")
92+
.json(&body?)
93+
} else {
94+
request
95+
};
96+
97+
let response = request.send().await?;
98+
99+
if response.status().is_success() {
100+
let result = response.json().await?;
101+
endpoint.validate_response(&result)?;
102+
Ok(result)
103+
} else {
104+
Err(ClientError::DBError(
105+
response.status(),
106+
response.text().await.unwrap_or_default(),
107+
))
108+
}
109+
}
110+
111+
/// Pings the DB instance to check if it is reachable.
112+
///
113+
/// ```
114+
/// # tokio_test::block_on(async {
115+
/// # let container = eventsourcingdb_client_rust::container::Container::start_default().await.unwrap();
116+
/// let db_url = "http://localhost:3000/";
117+
/// let api_token = "secrettoken";
118+
/// # let db_url = container.get_base_url().await.unwrap();
119+
/// # let api_token = container.get_api_token();
120+
/// let client = eventsourcingdb_client_rust::client::Client::new(db_url, api_token);
121+
/// client.ping().await.expect("Failed to ping");
122+
/// # })
123+
/// ```
124+
///
125+
/// # Errors
126+
/// This function will return an error if the request fails or if the URL is invalid.
127+
pub async fn ping(&self) -> Result<(), ClientError> {
128+
let _ = self.request(PingRequest).await?;
129+
Ok(())
130+
}
131+
132+
/// Verifies the API token by sending a request to the DB instance.
133+
///
134+
/// ```
135+
/// # tokio_test::block_on(async {
136+
/// # let container = eventsourcingdb_client_rust::container::Container::start_default().await.unwrap();
137+
/// let db_url = "http://localhost:3000/";
138+
/// let api_token = "secrettoken";
139+
/// # let db_url = container.get_base_url().await.unwrap();
140+
/// # let api_token = container.get_api_token();
141+
/// let client = eventsourcingdb_client_rust::client::Client::new(db_url, api_token);
142+
/// client.verify_api_token().await.expect("Failed to ping");
143+
/// # })
144+
/// ```
145+
///
146+
/// # Errors
147+
/// This function will return an error if the request fails or if the URL is invalid.
148+
pub async fn verify_api_token(&self) -> Result<(), ClientError> {
149+
let _ = self.request(VerifyApiTokenRequest).await?;
150+
Ok(())
151+
}
152+
}

src/client/client_request.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//! This is a purely internal module to represent client requests to the database.
2+
3+
use reqwest::Method;
4+
use serde_json::Value;
5+
6+
use crate::{error::ClientError, event::ManagementEvent};
7+
8+
/// Represents a request to the database client
9+
pub trait ClientRequest {
10+
const URL_PATH: &'static str;
11+
const METHOD: Method;
12+
type Response: serde::de::DeserializeOwned;
13+
14+
/// Returns the URL path for the request
15+
fn url_path(&self) -> &'static str {
16+
Self::URL_PATH
17+
}
18+
19+
/// Returns the http method type for the request
20+
fn method(&self) -> Method {
21+
Self::METHOD
22+
}
23+
24+
/// Returns the body for the request
25+
fn body(&self) -> Option<Result<Value, ClientError>> {
26+
None
27+
}
28+
29+
/// Validate the response from the database
30+
fn validate_response(&self, _response: &Self::Response) -> Result<(), ClientError> {
31+
Ok(())
32+
}
33+
}
34+
35+
/// Ping the Database instance
36+
#[derive(Debug, Clone, Copy)]
37+
pub struct PingRequest;
38+
39+
impl ClientRequest for PingRequest {
40+
const URL_PATH: &'static str = "/api/v1/ping";
41+
const METHOD: Method = Method::GET;
42+
type Response = ManagementEvent;
43+
44+
fn validate_response(&self, response: &Self::Response) -> Result<(), ClientError> {
45+
(response.ty() == "io.eventsourcingdb.api.ping-received")
46+
.then_some(())
47+
.ok_or(ClientError::PingFailed)
48+
}
49+
}
50+
51+
/// Verify the API token
52+
#[derive(Debug, Clone, Copy)]
53+
pub struct VerifyApiTokenRequest;
54+
55+
impl ClientRequest for VerifyApiTokenRequest {
56+
const URL_PATH: &'static str = "/api/v1/verify-api-token";
57+
const METHOD: Method = Method::POST;
58+
type Response = ManagementEvent;
59+
60+
fn validate_response(&self, response: &Self::Response) -> Result<(), ClientError> {
61+
(response.ty() == "io.eventsourcingdb.api.api-token-verified")
62+
.then_some(())
63+
.ok_or(ClientError::APITokenInvalid)
64+
}
65+
}

0 commit comments

Comments
 (0)