use async_trait::async_trait;
use chrono::SecondsFormat;
use reqwest::Client;
use serde::Deserialize;
use crate::{
embedded::{EventView, IngestEvent, Query},
error::{AllSourceError, Result},
};
use super::event_store::EventStore;
#[derive(Deserialize)]
struct QueryResponse {
events: Vec<EventView>,
}
pub struct HttpCore {
client: Client,
base_url: String,
api_key: Option<String>,
tenant_id: Option<String>,
}
impl HttpCore {
pub fn new(
base_url: impl Into<String>,
api_key: Option<String>,
tenant_id: Option<String>,
) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self {
client: Client::new(),
base_url,
api_key,
tenant_id,
}
}
fn effective_tenant<'a>(&'a self, event_tenant: Option<&'a str>) -> Option<&'a str> {
event_tenant.or(self.tenant_id.as_deref())
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.api_key {
Some(key) if !key.is_empty() => req.bearer_auth(key),
_ => req,
}
}
}
#[async_trait]
impl EventStore for HttpCore {
async fn ingest(&self, event: IngestEvent<'_>) -> Result<()> {
let body = serde_json::json!({
"event_type": event.event_type,
"entity_id": event.entity_id,
"payload": event.payload,
"metadata": event.metadata,
"tenant_id": self.effective_tenant(event.tenant_id),
});
let req = self
.client
.post(format!("{}/api/v1/events", self.base_url))
.json(&body);
let resp = self
.auth(req)
.send()
.await
.map_err(|e| AllSourceError::StorageError(format!("ingest to remote Core: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(AllSourceError::StorageError(format!(
"ingest to remote Core: HTTP {status}: {text}"
)));
}
Ok(())
}
async fn ingest_batch(&self, events: Vec<IngestEvent<'_>>) -> Result<()> {
let events_json: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"event_type": e.event_type,
"entity_id": e.entity_id,
"payload": e.payload,
"metadata": e.metadata,
"tenant_id": self.effective_tenant(e.tenant_id),
})
})
.collect();
let body = serde_json::json!({ "events": events_json });
let req = self
.client
.post(format!("{}/api/v1/events/batch", self.base_url))
.json(&body);
let resp = self.auth(req).send().await.map_err(|e| {
AllSourceError::StorageError(format!("batch ingest to remote Core: {e}"))
})?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(AllSourceError::StorageError(format!(
"batch ingest to remote Core: HTTP {status}: {text}"
)));
}
Ok(())
}
async fn query(&self, query: Query) -> Result<Vec<EventView>> {
let mut params: Vec<(&str, String)> = Vec::new();
if let Some(v) = &query.entity_id {
params.push(("entity_id", v.clone()));
}
if let Some(v) = &query.event_type {
params.push(("event_type", v.clone()));
}
if let Some(v) = &query.event_type_prefix {
params.push(("event_type_prefix", v.clone()));
}
if let Some(v) = query.limit {
params.push(("limit", v.to_string()));
}
if let Some(t) = query.since {
params.push(("since", t.to_rfc3339_opts(SecondsFormat::Micros, true)));
}
if let Some(t) = query.until {
params.push(("until", t.to_rfc3339_opts(SecondsFormat::Micros, true)));
}
if let Some(t) = self.effective_tenant(query.tenant_id.as_deref()) {
params.push(("tenant_id", t.to_string()));
}
let query_string = {
let mut qs = url::form_urlencoded::Serializer::new(String::new());
for (k, v) in ¶ms {
qs.append_pair(k, v);
}
qs.finish()
};
let url = if query_string.is_empty() {
format!("{}/api/v1/events/query", self.base_url)
} else {
format!("{}/api/v1/events/query?{query_string}", self.base_url)
};
let req = self.client.get(url);
let resp = self
.auth(req)
.send()
.await
.map_err(|e| AllSourceError::StorageError(format!("query remote Core: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(AllSourceError::StorageError(format!(
"query remote Core: HTTP {status}: {text}"
)));
}
let body: QueryResponse = resp
.json()
.await
.map_err(|e| AllSourceError::StorageError(format!("parse query response: {e}")))?;
Ok(body.events)
}
async fn shutdown(&self) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path, query_param},
};
fn event_json() -> serde_json::Value {
serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"event_type": "prime.node.created",
"entity_id": "node:contact:alice",
"tenant_id": "tenant-a",
"payload": {"name": "Alice"},
"metadata": null,
"timestamp": "2026-06-06T00:00:00Z",
"version": 1
})
}
#[tokio::test]
async fn query_scopes_to_tenant_and_parses_events() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/events/query"))
.and(query_param("tenant_id", "tenant-a"))
.and(query_param("event_type_prefix", "prime."))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "events": [event_json()], "count": 1 })),
)
.mount(&server)
.await;
let core = HttpCore::new(server.uri(), None, Some("tenant-a".to_string()));
let events = core
.query(Query::new().event_type_prefix("prime."))
.await
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].entity_id, "node:contact:alice");
assert_eq!(events[0].tenant_id, "tenant-a");
}
#[tokio::test]
async fn ingest_posts_event_stamped_with_tenant() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/events"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
.expect(1)
.mount(&server)
.await;
let core = HttpCore::new(
server.uri(),
Some("k".to_string()),
Some("tenant-a".to_string()),
);
core.ingest(IngestEvent {
entity_id: "node:contact:bob",
event_type: "prime.node.created",
payload: serde_json::json!({"name": "Bob"}),
metadata: None,
tenant_id: None,
})
.await
.unwrap();
}
#[tokio::test]
async fn non_2xx_is_an_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/events/query"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let core = HttpCore::new(server.uri(), None, Some("tenant-a".to_string()));
let err = core.query(Query::new()).await.unwrap_err();
assert!(matches!(err, AllSourceError::StorageError(_)));
}
}