mod body;
mod config;
mod error;
#[cfg(feature = "spool")]
pub mod spool;
mod transport;
pub use body::{CloseCapsuleBody, CreatePinBody};
pub use config::{
default_port_path, default_socket_path, ClientConfig, Spawner, Transport,
EXPECTED_SCHEMA_VERSION,
};
pub use error::ClientError;
use hyper::StatusCode;
use serde::de::DeserializeOwned;
use serde::Deserialize;
pub use kindling_types::{
CandidateResult, Capsule, CapsuleStatus, CapsuleType, Id, Observation, ObservationInput,
ObservationKind, Pin, PinResult, PinTargetType, ProviderSearchOptions, ProviderSearchResult,
RetrieveOptions, RetrieveProvenance, RetrieveResult, RetrievedEntity, ScopeIds, Summary,
Timestamp,
};
use body::{
AppendObservationBody, OpenCapsuleBody, PreCompactContextBody, SessionStartContextBody,
};
pub const PROJECT_HEADER: &str = "x-kindling-project";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Health {
pub version: String,
pub schema_version: u32,
pub projects: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HealthBody {
version: String,
schema_version: u32,
#[serde(default)]
projects: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ErrorBody {
error: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ContextBody {
#[serde(default)]
additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Client {
config: ClientConfig,
}
impl Client {
pub fn new() -> Result<Self, ClientError> {
Ok(Self {
config: ClientConfig::defaults()?,
})
}
pub fn with_config(config: ClientConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &ClientConfig {
&self.config
}
pub async fn health(&self) -> Result<Health, ClientError> {
let body: HealthBody = self
.call(
"GET",
"/v1/health",
false,
None::<&()>,
&[StatusCode::OK],
)
.await?;
let expected = self.config.expected_schema_version;
if body.schema_version != expected {
return Err(ClientError::SchemaMismatch {
expected,
actual: body.schema_version,
});
}
Ok(Health {
version: body.version,
schema_version: body.schema_version,
projects: body.projects,
})
}
pub async fn open_capsule(
&self,
kind: CapsuleType,
intent: impl Into<String>,
scope_ids: ScopeIds,
id: Option<Id>,
) -> Result<Capsule, ClientError> {
let body = OpenCapsuleBody {
kind,
intent: intent.into(),
scope_ids,
id,
};
self.call(
"POST",
"/v1/capsules",
true,
Some(&body),
&[StatusCode::CREATED],
)
.await
}
pub async fn get_open_capsule(&self, session_id: &str) -> Result<Option<Capsule>, ClientError> {
let path = format!(
"/v1/capsules/open?sessionId={}",
percent_encode_query(session_id)
);
self.call("GET", &path, true, None::<&()>, &[StatusCode::OK])
.await
}
pub async fn close_capsule(
&self,
capsule_id: &str,
body: CloseCapsuleBody,
) -> Result<Capsule, ClientError> {
let path = format!("/v1/capsules/{}/close", capsule_id);
self.call("PATCH", &path, true, Some(&body), &[StatusCode::OK])
.await
}
pub async fn append_observation(
&self,
input: ObservationInput,
capsule_id: Option<Id>,
validate: Option<bool>,
) -> Result<Observation, ClientError> {
let body = AppendObservationBody {
input,
capsule_id,
validate,
};
self.call(
"POST",
"/v1/observations",
true,
Some(&body),
&[StatusCode::CREATED],
)
.await
}
pub async fn retrieve(&self, options: RetrieveOptions) -> Result<RetrieveResult, ClientError> {
self.call(
"POST",
"/v1/retrieve",
true,
Some(&options),
&[StatusCode::OK],
)
.await
}
pub async fn pin(&self, body: CreatePinBody) -> Result<Pin, ClientError> {
self.call(
"POST",
"/v1/pins",
true,
Some(&body),
&[StatusCode::CREATED],
)
.await
}
pub async fn unpin(&self, pin_id: &str) -> Result<(), ClientError> {
let path = format!("/v1/pins/{}", pin_id);
self.call_no_content("DELETE", &path, true, &[StatusCode::NO_CONTENT])
.await
}
pub async fn forget(&self, observation_id: &str) -> Result<(), ClientError> {
let path = format!("/v1/observations/{}/forget", observation_id);
self.call_no_content("POST", &path, true, &[StatusCode::NO_CONTENT])
.await
}
pub async fn session_start_context(
&self,
max_results: Option<u32>,
) -> Result<Option<String>, ClientError> {
let body = SessionStartContextBody {
max_results,
scope_ids: self.project_scope(),
};
let resp: ContextBody = self
.call(
"POST",
"/v1/context/session-start",
true,
Some(&body),
&[StatusCode::OK],
)
.await?;
Ok(resp.additional_context)
}
pub async fn pre_compact_context(&self) -> Result<Option<String>, ClientError> {
let body = PreCompactContextBody {
scope_ids: self.project_scope(),
};
let resp: ContextBody = self
.call(
"POST",
"/v1/context/pre-compact",
true,
Some(&body),
&[StatusCode::OK],
)
.await?;
Ok(resp.additional_context)
}
fn project_scope(&self) -> ScopeIds {
ScopeIds {
repo_id: Some(self.config.project_root.clone()),
..Default::default()
}
}
async fn call<B, T>(
&self,
method: &str,
path: &str,
project: bool,
body: Option<&B>,
expected: &[StatusCode],
) -> Result<T, ClientError>
where
B: serde::Serialize,
T: DeserializeOwned,
{
let raw = self.send(method, path, project, body).await?;
ensure_status(&raw, expected)?;
serde_json::from_slice(&raw.body)
.map_err(|e| ClientError::Decode(format!("{e}: body was {:?}", raw.body)))
}
async fn call_no_content(
&self,
method: &str,
path: &str,
project: bool,
expected: &[StatusCode],
) -> Result<(), ClientError> {
let raw = self.send(method, path, project, None::<&()>).await?;
ensure_status(&raw, expected)?;
Ok(())
}
async fn send<B>(
&self,
method: &str,
path: &str,
project: bool,
body: Option<&B>,
) -> Result<transport::RawResponse, ClientError>
where
B: serde::Serialize,
{
let body_str = match body {
Some(b) => serde_json::to_string(b)
.map_err(|e| ClientError::Decode(format!("serializing request body: {e}")))?,
None => String::new(),
};
let project_header = if project {
Some(self.config.project_root.as_str())
} else {
None
};
transport::request(
&self.config,
transport::OutgoingRequest {
method,
path,
project: project_header,
body: body_str,
},
)
.await
}
}
fn percent_encode_query(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char);
}
other => {
out.push('%');
out.push(
char::from_digit((other >> 4) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
out.push(
char::from_digit((other & 0xf) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
}
}
}
out
}
fn ensure_status(raw: &transport::RawResponse, expected: &[StatusCode]) -> Result<(), ClientError> {
if expected.contains(&raw.status) {
return Ok(());
}
let message = serde_json::from_slice::<ErrorBody>(&raw.body)
.map(|b| b.error)
.unwrap_or_else(|_| {
if raw.body.is_empty() {
raw.status
.canonical_reason()
.unwrap_or("unknown error")
.to_string()
} else {
String::from_utf8_lossy(&raw.body).into_owned()
}
});
Err(ClientError::Api {
status: raw.status.as_u16(),
message,
})
}