Skip to content

Commit 4c65577

Browse files
committed
feat(client): add first Client implementation adhering to the "Essentials" compliance criteria
Signed-off-by: Raphael Höser <[email protected]>
1 parent 48fd60f commit 4c65577

File tree

12 files changed

+493
-40
lines changed

12 files changed

+493
-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 = "0.12.15"
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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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;
23+
24+
use reqwest;
25+
use url::Url;
26+
27+
use crate::{error::ClientError, event::ManagementEvent};
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(&self, endpoint: ClientRequest) -> Result<reqwest::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.json() {
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+
Ok(response)
101+
} else {
102+
Err(ClientError::DBError(
103+
response.status(),
104+
response.text().await.unwrap_or_default(),
105+
))
106+
}
107+
}
108+
109+
/// Pings the DB instance to check if it is reachable.
110+
///
111+
/// ```
112+
/// # tokio_test::block_on(async {
113+
/// # let container = eventsourcingdb_client_rust::container::Container::start_default().await.unwrap();
114+
/// let db_url = "http://localhost:3000/";
115+
/// let api_token = "secrettoken";
116+
/// # let db_url = container.get_base_url().await.unwrap();
117+
/// # let api_token = container.get_api_token();
118+
/// let client = eventsourcingdb_client_rust::client::Client::new(db_url, api_token);
119+
/// client.ping().await.expect("Failed to ping");
120+
/// # })
121+
/// ```
122+
///
123+
/// # Errors
124+
/// This function will return an error if the request fails or if the URL is invalid.
125+
pub async fn ping(&self) -> Result<(), ClientError> {
126+
let response = self.request(ClientRequest::Ping).await?;
127+
if response.json::<ManagementEvent>().await?.ty() == "io.eventsourcingdb.api.ping-received"
128+
{
129+
Ok(())
130+
} else {
131+
Err(ClientError::PingFailed)
132+
}
133+
}
134+
135+
/// Verifies the API token by sending a request to the DB instance.
136+
///
137+
/// ```
138+
/// # tokio_test::block_on(async {
139+
/// # let container = eventsourcingdb_client_rust::container::Container::start_default().await.unwrap();
140+
/// let db_url = "http://localhost:3000/";
141+
/// let api_token = "secrettoken";
142+
/// # let db_url = container.get_base_url().await.unwrap();
143+
/// # let api_token = container.get_api_token();
144+
/// let client = eventsourcingdb_client_rust::client::Client::new(db_url, api_token);
145+
/// client.verify_api_token().await.expect("Failed to ping");
146+
/// # })
147+
/// ```
148+
///
149+
/// # Errors
150+
/// This function will return an error if the request fails or if the URL is invalid.
151+
pub async fn verify_api_token(&self) -> Result<(), ClientError> {
152+
let response = self.request(ClientRequest::VerifyApiToken).await?;
153+
if response.json::<ManagementEvent>().await?.ty()
154+
== "io.eventsourcingdb.api.api-token-verified"
155+
{
156+
Ok(())
157+
} else {
158+
Err(ClientError::APITokenInvalid)
159+
}
160+
}
161+
}

src/client/client_request.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
use reqwest::Method;
2+
use serde_json::Value;
3+
4+
use crate::error::ClientError;
5+
6+
/// Enum for different requests that can be made to the DB
7+
#[derive(Debug)]
8+
pub enum ClientRequest {
9+
/// Ping the DB instance to check if it is reachable
10+
Ping,
11+
/// Verify the API token by sending a request to the DB instance
12+
VerifyApiToken,
13+
}
14+
15+
impl ClientRequest {
16+
/// Returns the URL path for the request
17+
#[must_use]
18+
pub fn url_path(&self) -> &'static str {
19+
match self {
20+
ClientRequest::Ping => "/api/v1/ping",
21+
ClientRequest::VerifyApiToken => "/api/v1/verify-api-token",
22+
}
23+
}
24+
25+
/// Returns the http method type for the request
26+
#[must_use]
27+
pub fn method(&self) -> Method {
28+
match self {
29+
ClientRequest::Ping => Method::GET,
30+
ClientRequest::VerifyApiToken => Method::POST,
31+
}
32+
}
33+
34+
/// Returns the body for the request
35+
#[must_use]
36+
pub fn json(self) -> Option<Result<Value, ClientError>> {
37+
match self {
38+
ClientRequest::Ping | ClientRequest::VerifyApiToken => None,
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)