pub mod auth;
mod error;
pub use error::{ApiError, Error};
pub use secrecy::{ExposeSecret, SecretString};
use error::root_cause;
use reqwest::{Client as HttpClient, Method};
use serde::Serialize;
use serde_json::Value;
use std::{fmt, time::Duration};
use url::Url;
use uuid::Uuid;
pub const DEFAULT_API_URL: &str = "https://api.commit.teamofsilicons.com";
pub const CONTRACT_VERSION: u16 = 2;
const RESPONSE_LIMIT: usize = 8 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct Mutation {
key: String,
version: Option<i64>,
}
impl Default for Mutation {
fn default() -> Self {
Self::new()
}
}
impl Mutation {
pub fn new() -> Self {
Self {
key: Uuid::new_v4().to_string(),
version: None,
}
}
pub fn with_key(key: impl Into<String>) -> Result<Self, Error> {
let key = key.into();
if !(8..=255).contains(&key.len()) || !key.bytes().all(|b| b.is_ascii_graphic()) {
return Err(Error::Invalid(
"An idempotency key must be 8 to 255 visible ASCII characters (no spaces).".into(),
));
}
Ok(Self { key, version: None })
}
pub fn if_match(mut self, version: i64) -> Result<Self, Error> {
if version < 0 {
return Err(Error::Invalid(format!(
"If-Match versions start at 0; {version} is negative."
)));
}
self.version = Some(version);
Ok(self)
}
pub fn key(&self) -> &str {
&self.key
}
}
#[derive(Clone)]
enum Credential {
Bearer(SecretString),
Proof(SecretString),
}
#[derive(Clone)]
pub struct Client {
http: HttpClient,
base: Url,
credential: Option<Credential>,
mutation: Option<Mutation>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let credential = match &self.credential {
None => "none",
Some(Credential::Bearer(_)) => "bearer <redacted>",
Some(Credential::Proof(_)) => "proof <redacted>",
};
f.debug_struct("Client")
.field("base_url", &self.base.as_str())
.field("credential", &credential)
.finish_non_exhaustive()
}
}
pub fn is_loopback(url: &Url) -> bool {
match url.host() {
Some(url::Host::Domain(domain)) => domain == "localhost" || domain.ends_with(".localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
impl Client {
pub fn new(base_url: &str) -> Result<Self, Error> {
let invalid = |reason| Error::InvalidUrl {
url: base_url.to_owned(),
reason,
};
let mut base =
Url::parse(base_url.trim()).map_err(|_| invalid("it is not an absolute URL"))?;
match base.scheme() {
"https" => {}
"http" if is_loopback(&base) => {}
"http" => {
return Err(invalid(
"plain http is only allowed for this machine (localhost, 127.0.0.1, ::1); use https",
));
}
_ => {
return Err(invalid(
"only https (and http for this machine) are supported",
));
}
}
if base.host_str().is_none() || !base.username().is_empty() || base.password().is_some() {
return Err(invalid("it must name a host and carry no credentials"));
}
if base.query().is_some() || base.fragment().is_some() {
return Err(invalid("it must not have a query string or fragment"));
}
if !matches!(base.path(), "" | "/" | "/api/v1" | "/api/v1/") {
return Err(invalid("use the origin, or the origin followed by /api/v1"));
}
base.set_path("/api/v1/");
let http = HttpClient::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.user_agent(concat!("silicon-commit-client/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| {
Error::Invalid(format!(
"Could not create the HTTP client: {}.",
root_cause(&e)
))
})?;
Ok(Self {
http,
base,
credential: None,
mutation: None,
})
}
#[must_use]
pub fn with_bearer(mut self, access_token: impl Into<String>) -> Self {
self.credential = Some(Credential::Bearer(SecretString::from(access_token.into())));
self
}
pub fn with_proof(mut self, proof: impl Into<String>) -> Result<Self, Error> {
let proof = proof.into();
let proof = proof.trim();
if proof.starts_with("sapr_") || !proof.starts_with("sap_") {
return Err(Error::Invalid(
"A User verification proof starts with `sap_` (a `sapr_…` value is the proof's refresh token, which is never sent to Commit).".into(),
));
}
self.credential = Some(Credential::Proof(SecretString::from(proof.to_owned())));
Ok(self)
}
#[deprecated(
since = "0.6.0",
note = "Commit records no telemetry; this does nothing and can be removed"
)]
#[must_use]
pub fn with_telemetry(self, _enabled: bool) -> Self {
self
}
#[deprecated(
since = "0.6.0",
note = "Commit records no request diagnostics; this does nothing and can be removed"
)]
pub fn with_source(self, source: &'static str) -> Result<Self, Error> {
if !matches!(source, "cli" | "browser" | "daemon" | "rust-client") {
return Err(Error::Invalid(format!(
"`{source}` is not a client source; use cli, browser, daemon or rust-client."
)));
}
Ok(self)
}
#[must_use]
pub fn with_mutation(mut self, mutation: Mutation) -> Self {
self.mutation = Some(mutation);
self
}
pub fn base_url(&self) -> &Url {
&self.base
}
pub fn new_idempotency_key() -> String {
Mutation::new().key
}
}
impl Client {
pub async fn health(&self) -> Result<Value, Error> {
self.execute(Method::GET, &["healthz"], None, &[], Scope::Root)
.await
}
pub async fn ready(&self) -> Result<Value, Error> {
self.execute(Method::GET, &["readyz"], None, &[], Scope::Root)
.await
}
pub async fn version(&self) -> Result<Value, Error> {
self.execute(Method::GET, &["version"], None, &[], Scope::Public)
.await
}
pub async fn contracts(&self) -> Result<Value, Error> {
self.get(&["contracts"], &[]).await
}
pub async fn accounts(&self) -> Result<Value, Error> {
self.execute(Method::GET, &["accounts"], None, &[], Scope::Public)
.await
}
pub async fn me(&self) -> Result<Value, Error> {
self.get(&["me"], &[]).await
}
pub async fn silicon_allowlist(&self, silicon: &str) -> Result<Value, Error> {
self.get(&["silicons", silicon, "allowed-accounts"], &[])
.await
}
pub async fn allow_account(&self, silicon: &str, account: &str) -> Result<Value, Error> {
self.execute(
Method::PUT,
&["silicons", silicon, "allowed-accounts", account],
None,
&[],
Scope::Api,
)
.await
}
pub async fn disallow_account(&self, silicon: &str, account: &str) -> Result<Value, Error> {
self.execute(
Method::DELETE,
&["silicons", silicon, "allowed-accounts", account],
None,
&[],
Scope::Api,
)
.await
}
pub async fn email_settings(&self) -> Result<Value, Error> {
self.get(&["email-settings"], &[]).await
}
pub async fn set_email_settings(&self, body: &Value) -> Result<Value, Error> {
self.write(Method::PUT, &["email-settings"], body).await
}
pub async fn report(&self, body: &Value) -> Result<Value, Error> {
self.write(Method::POST, &["reports"], body).await
}
}
impl Client {
pub async fn list_todos(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
self.get(&["todos"], query).await
}
pub async fn get_todo(&self, todo_id: &str) -> Result<Value, Error> {
self.get(&["todos", todo_id], &[]).await
}
pub async fn create_todo<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
self.write(Method::POST, &["todos"], body).await
}
pub async fn update_todo<T: Serialize + ?Sized>(
&self,
todo_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::PATCH, &["todos", todo_id], body).await
}
pub async fn delete_todo(&self, todo_id: &str) -> Result<Value, Error> {
self.execute(Method::DELETE, &["todos", todo_id], None, &[], Scope::Api)
.await
}
pub async fn list_notes(&self, todo_id: &str, query: &[(&str, &str)]) -> Result<Value, Error> {
self.get(&["todos", todo_id, "notes"], query).await
}
pub async fn add_note<T: Serialize + ?Sized>(
&self,
todo_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["todos", todo_id, "notes"], body)
.await
}
pub async fn todo_subscription(&self, todo_id: &str) -> Result<Value, Error> {
self.get(&["todos", todo_id, "notification-subscription"], &[])
.await
}
pub async fn replace_todo_subscription<T: Serialize + ?Sized>(
&self,
todo_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(
Method::PUT,
&["todos", todo_id, "notification-subscription"],
body,
)
.await
}
pub async fn notification_settings(&self) -> Result<Value, Error> {
self.get(&["notification-settings"], &[]).await
}
pub async fn notification_settings_of(&self, silicon: &str) -> Result<Value, Error> {
self.get(&["notification-settings"], &[("silicon", silicon)])
.await
}
pub async fn update_notification_settings<T: Serialize + ?Sized>(
&self,
body: &T,
) -> Result<Value, Error> {
self.write(Method::PUT, &["notification-settings"], body)
.await
}
pub async fn update_notification_settings_of<T: Serialize + ?Sized>(
&self,
silicon: &str,
body: &T,
) -> Result<Value, Error> {
let body = serde_json::to_vec(body).map_err(Error::Decode)?;
self.execute(
Method::PUT,
&["notification-settings"],
Some(body),
&[("silicon", silicon)],
Scope::Api,
)
.await
}
}
impl Client {
pub async fn list_projects(&self, query: &[(&str, &str)]) -> Result<Value, Error> {
self.get(&["projects"], query).await
}
pub async fn get_project(&self, project_id: &str) -> Result<Value, Error> {
self.get(&["projects", project_id], &[]).await
}
pub async fn create_project<T: Serialize + ?Sized>(&self, body: &T) -> Result<Value, Error> {
self.write(Method::POST, &["projects"], body).await
}
pub async fn update_project<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::PATCH, &["projects", project_id], body)
.await
}
pub async fn project_diary(&self, project_id: &str) -> Result<Value, Error> {
self.get(&["projects", project_id, "diary"], &[]).await
}
pub async fn replace_project_diary<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::PUT, &["projects", project_id, "diary"], body)
.await
}
pub async fn project_tasks(
&self,
project_id: &str,
query: &[(&str, &str)],
) -> Result<Value, Error> {
self.get(&["projects", project_id, "tasks"], query).await
}
pub async fn project_entries(
&self,
project_id: &str,
query: &[(&str, &str)],
) -> Result<Value, Error> {
self.get(&["projects", project_id, "entries"], query).await
}
pub async fn create_project_task<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["projects", project_id, "tasks"], body)
.await
}
pub async fn update_project_task<T: Serialize + ?Sized>(
&self,
project_id: &str,
task_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(
Method::PATCH,
&["projects", project_id, "tasks", task_id],
body,
)
.await
}
pub async fn claim_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
self.write(
Method::POST,
&["projects", project, "tasks", task, "claim"],
&serde_json::json!({}),
)
.await
}
pub async fn delete_project_task(&self, project: &str, task: &str) -> Result<Value, Error> {
self.execute(
Method::DELETE,
&["projects", project, "tasks", task],
None,
&[],
Scope::Api,
)
.await
}
pub async fn create_project_blocker<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["projects", project_id, "blockers"], body)
.await
}
pub async fn create_project_update<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["projects", project_id, "updates"], body)
.await
}
pub async fn complete_project<T: Serialize + ?Sized>(
&self,
project_id: &str,
body: &T,
) -> Result<Value, Error> {
self.write(Method::POST, &["projects", project_id, "completion"], body)
.await
}
pub async fn project_versions(
&self,
project: &str,
query: &[(&str, &str)],
) -> Result<Value, Error> {
self.get(&["projects", project, "versions"], query).await
}
pub async fn project_version(&self, project: &str, version: i64) -> Result<Value, Error> {
self.get(
&["projects", project, "versions", &version.to_string()],
&[],
)
.await
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Api,
Public,
Root,
}
impl Client {
async fn get(&self, path: &[&str], query: &[(&str, &str)]) -> Result<Value, Error> {
self.execute(Method::GET, path, None, query, Scope::Api)
.await
}
async fn write<T: Serialize + ?Sized>(
&self,
method: Method,
path: &[&str],
body: &T,
) -> Result<Value, Error> {
let body = serde_json::to_vec(body).map_err(Error::Decode)?;
self.execute(method, path, Some(body), &[], Scope::Api)
.await
}
fn url(&self, path: &[&str], scope: Scope) -> Result<Url, Error> {
if path
.iter()
.any(|p| p.is_empty() || matches!(*p, "." | ".."))
{
return Err(Error::Invalid(
"Resource identifiers cannot be empty, `.` or `..`.".into(),
));
}
let mut url = self.base.clone();
{
let mut segments = url.path_segments_mut().map_err(|()| Error::InvalidUrl {
url: self.base.to_string(),
reason: "it cannot carry a path",
})?;
if scope == Scope::Root {
segments.clear();
} else {
segments.pop_if_empty();
}
segments.extend(path);
}
Ok(url)
}
fn transport(&self, error: reqwest::Error) -> Error {
let error = error.without_url();
Error::Transport {
base: self.base.origin().ascii_serialization(),
cause: root_cause(&error),
source: error,
}
}
async fn execute(
&self,
method: Method,
path: &[&str],
body: Option<Vec<u8>>,
query: &[(&str, &str)],
scope: Scope,
) -> Result<Value, Error> {
let is_mutation = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
let mut request = self
.http
.request(method, self.url(path, scope)?)
.query(query)
.header("x-commit-supported-versions", CONTRACT_VERSION.to_string())
.header("x-commit-telemetry", "off");
if scope == Scope::Api {
match &self.credential {
Some(Credential::Bearer(token)) => {
request = request.bearer_auth(token.expose_secret())
}
Some(Credential::Proof(proof)) => {
request =
request.header("authorization", format!("Proof {}", proof.expose_secret()));
}
None => {}
}
}
if is_mutation {
let mutation = self.mutation.clone().unwrap_or_default();
request = request.header("idempotency-key", mutation.key);
if let Some(version) = mutation.version {
request = request.header("if-match", format!("\"{version}\""));
}
}
if let Some(body) = body {
request = request
.header("content-type", "application/json")
.body(body);
}
let mut response = request.send().await.map_err(|e| self.transport(e))?;
if let Some(served) = response.headers().get("x-commit-api-version")
&& served.as_bytes() != CONTRACT_VERSION.to_string().as_bytes()
{
return Err(Error::UnsupportedContract {
served: String::from_utf8_lossy(served.as_bytes()).into_owned(),
});
}
let status = response.status();
let headers = response.headers().clone();
if response
.content_length()
.is_some_and(|n| n > RESPONSE_LIMIT as u64)
{
return Err(Error::ResponseTooLarge);
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|e| self.transport(e))? {
if bytes.len() + chunk.len() > RESPONSE_LIMIT {
return Err(Error::ResponseTooLarge);
}
bytes.extend_from_slice(&chunk);
}
if !status.is_success() {
return Err(ApiError::from_response(status, &headers, &bytes).into());
}
if bytes.is_empty() {
return Ok(Value::Null);
}
serde_json::from_slice(&bytes).map_err(Error::Decode)
}
}