use std::ops::ControlFlow;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use futures_util::StreamExt;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use super::models::*;
use crate::config::{host_of, normalise_url};
mod accounts;
mod data;
mod deployments;
mod domains;
mod projects;
mod services;
mod storage;
mod variables;
mod workflows;
#[derive(Clone)]
pub struct ApiClient {
base: String,
http: reqwest::Client,
token: Option<String>,
}
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
const SILENCE_TIMEOUT: Duration = Duration::from_secs(90);
#[derive(Debug)]
pub struct ApiError {
pub status: StatusCode,
pub code: String,
pub message: String,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{} ({})", self.message, self.status.as_u16())
}
}
impl std::error::Error for ApiError {}
#[derive(serde::Deserialize)]
struct ErrorBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
message: Option<String>,
}
impl ApiClient {
pub fn new(base: &str, token: Option<String>) -> Result<Self> {
require_transport_security(base)?;
let http = reqwest::Client::builder()
.read_timeout(SILENCE_TIMEOUT)
.connect_timeout(Duration::from_secs(10))
.user_agent(concat!("fundaia/", env!("CARGO_PKG_VERSION")))
.build()
.context("could not build the HTTP client")?;
Ok(Self {
base: normalise_url(base),
http,
token,
})
}
pub fn with_token(&self, token: String) -> Self {
Self {
token: Some(token),
..self.clone()
}
}
pub fn base(&self) -> &str {
&self.base
}
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
self.send(Method::GET, path, None::<&()>).await
}
pub async fn post<T: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<T> {
self.send(Method::POST, path, Some(body)).await
}
pub async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
self.post(path, &serde_json::json!({})).await
}
pub async fn put<T: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<T> {
self.send(Method::PUT, path, Some(body)).await
}
pub async fn patch<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
let response = self.request(Method::PATCH, path, Some(body)).send().await?;
Self::check(response).await?;
Ok(())
}
pub async fn patch_for<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T> {
self.send(Method::PATCH, path, Some(body)).await
}
pub async fn delete_for<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
self.send(Method::DELETE, path, None::<&()>).await
}
pub async fn delete(&self, path: &str) -> Result<()> {
let response = self
.request(Method::DELETE, path, None::<&()>)
.send()
.await?;
Self::check(response).await?;
Ok(())
}
async fn send<T: DeserializeOwned, B: Serialize>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> Result<T> {
let response = self.request(method, path, body).send().await?;
let text = Self::check(response).await?;
if text.trim().is_empty() {
bail!("the server answered {path} with nothing at all");
}
serde_json::from_str(&text)
.with_context(|| format!("could not read the answer from {path}"))
}
fn request<B: Serialize>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> reqwest::RequestBuilder {
self.unbounded_request(method, path, body)
.timeout(REQUEST_TIMEOUT)
}
fn streaming_request(&self, path: &str) -> reqwest::RequestBuilder {
self.unbounded_request(Method::GET, path, None::<&()>)
.header("accept", "text/event-stream")
}
fn unbounded_request<B: Serialize>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> reqwest::RequestBuilder {
let mut builder = self.http.request(method, format!("{}{path}", self.base));
if let Some(token) = &self.token {
builder = builder.bearer_auth(token);
}
if let Some(body) = body {
builder = builder.json(body);
}
builder
}
async fn check(response: reqwest::Response) -> Result<String> {
let status = response.status();
let text = response.text().await.unwrap_or_default();
if status.is_success() {
return Ok(text);
}
let body: ErrorBody = serde_json::from_str(&text).unwrap_or(ErrorBody {
error: None,
message: None,
});
let message = body.message.unwrap_or_else(|| match status {
StatusCode::UNAUTHORIZED => "not signed in — run `fundaia login`".to_owned(),
StatusCode::NOT_FOUND => "there is nothing there".to_owned(),
_ => status
.canonical_reason()
.unwrap_or("the request failed")
.to_owned(),
});
Err(ApiError {
status,
code: body.error.unwrap_or_else(|| "unknown_error".to_owned()),
message,
}
.into())
}
pub async fn stream_events<F>(&self, mut on_event: F) -> Result<()>
where
F: FnMut(InfraEvent),
{
self.read_events("/api/infra/stream", |event| {
if event.contains("event: infra") {
if let Some(parsed) = parse_infra_event(event) {
on_event(parsed);
}
}
ControlFlow::Continue(())
})
.await
}
async fn read_events(
&self,
path: &str,
mut on_event: impl FnMut(&str) -> ControlFlow<()>,
) -> Result<()> {
let response = self.streaming_request(path).send().await?;
if !response.status().is_success() {
Self::check(response).await?;
return Ok(());
}
let mut stream = response.bytes_stream();
let mut pending: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
pending.extend_from_slice(&chunk?);
if hand_over_events(&mut pending, &mut on_event).is_break() {
break;
}
}
Ok(())
}
}
trait Named {
fn id(&self) -> &str;
fn slug(&self) -> &str;
fn name(&self) -> &str;
}
macro_rules! named {
($($kind:ty),+) => {$(
impl Named for $kind {
fn id(&self) -> &str { &self.id }
fn slug(&self) -> &str { &self.slug }
fn name(&self) -> &str { &self.name }
}
)+};
}
named!(Project, Service, Workspace);
impl<T: Named> Named for &T {
fn id(&self) -> &str {
(*self).id()
}
fn slug(&self) -> &str {
(*self).slug()
}
fn name(&self) -> &str {
(*self).name()
}
}
fn one_named<T: Named>(
candidates: impl IntoIterator<Item = T>,
reference: &str,
plural: &str,
missing: impl FnOnce() -> String,
) -> Result<T> {
let needle = reference.to_lowercase();
let mut matched = candidates.into_iter().filter(|candidate| {
candidate.id() == reference
|| candidate.slug().to_lowercase() == needle
|| candidate.name().to_lowercase() == needle
});
let Some(found) = matched.next() else {
return Err(anyhow!(missing()));
};
match matched.count() {
0 => Ok(found),
others => Err(anyhow!(
"`{reference}` matches {} {plural} — use the id",
others + 1
)),
}
}
fn service_named<T: Named>(services: impl IntoIterator<Item = T>, reference: &str) -> Result<T> {
one_named(services, reference, "services", || {
format!("this project has no service called `{reference}`")
})
}
fn stage_query(stage: Option<&str>) -> String {
stage.map_or_else(String::new, |name| format!("&stage={name}"))
}
fn trace_query(trace: Option<&str>) -> String {
trace.map_or_else(String::new, |id| format!("&traceId={id}"))
}
fn hand_over_events(
pending: &mut Vec<u8>,
on_event: &mut impl FnMut(&str) -> ControlFlow<()>,
) -> ControlFlow<()> {
let mut start = 0;
let mut outcome = ControlFlow::Continue(());
while let Some(blank) = pending[start..].windows(2).position(|pair| pair == b"\n\n") {
let end = start + blank + 2;
outcome = on_event(&String::from_utf8_lossy(&pending[start..end]));
start = end;
if outcome.is_break() {
break;
}
}
pending.drain(..start);
outcome
}
fn parse_log_event(event: &str) -> Option<LogLine> {
serde_json::from_str::<LogLine>(&payload_of(event)?).ok()
}
fn parse_infra_event(event: &str) -> Option<InfraEvent> {
serde_json::from_str::<InfraEvent>(&payload_of(event)?).ok()
}
fn payload_of(event: &str) -> Option<String> {
let payload: String = event
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim_start)
.collect::<Vec<_>>()
.join("\n");
if payload.is_empty() {
None
} else {
Some(payload)
}
}
fn require_transport_security(base: &str) -> Result<()> {
let trimmed = base.trim();
let scheme = trimmed
.split_once("://")
.map(|(scheme, _)| scheme.to_ascii_lowercase());
match scheme.as_deref() {
Some("https") => Ok(()),
Some("http") => {
let host = host_of(trimmed);
let name = host
.rsplit_once(':')
.map_or(host.as_str(), |(name, _)| name);
if is_loopback(name) || std::env::var("FUNDAIA_INSECURE_HTTP").is_ok_and(|v| v == "1") {
Ok(())
} else {
bail!(
"{trimmed} is plain http: the token would travel in clear. Use https, or set FUNDAIA_INSECURE_HTTP=1 if you really mean it"
)
}
}
_ => bail!("{trimmed} is not an http(s) address"),
}
}
fn is_loopback(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
|| host.strip_suffix(".localhost").is_some()
|| host.starts_with("127.")
}
#[cfg(test)]
mod tests {
use super::*;
fn workspace(id: &str, slug: &str, name: &str) -> Workspace {
Workspace {
id: id.to_owned(),
slug: slug.to_owned(),
name: name.to_owned(),
role: None,
}
}
fn spaces() -> Vec<Workspace> {
vec![
workspace("wsp_1", "acme", "Acme"),
workspace("wsp_2", "equipo", "Equipo"),
workspace("wsp_3", "otro", "equipo"),
]
}
#[test]
fn it_should_find_a_name_whatever_its_case() {
let found = one_named(spaces(), "ACME", "workspaces", String::new).unwrap();
assert_eq!(found.id, "wsp_1");
}
#[test]
fn it_should_find_an_id_exactly() {
let found = one_named(spaces(), "wsp_2", "workspaces", String::new).unwrap();
assert_eq!(found.slug, "equipo");
}
#[test]
fn it_should_refuse_a_reference_that_names_two() {
let refused = one_named(spaces(), "equipo", "workspaces", String::new).unwrap_err();
assert_eq!(
refused.to_string(),
"`equipo` matches 2 workspaces — use the id"
);
}
#[test]
fn it_should_say_what_it_could_not_find() {
let refused =
one_named(spaces(), "nadie", "workspaces", || "no está".to_owned()).unwrap_err();
assert_eq!(refused.to_string(), "no está");
}
#[test]
fn it_should_read_a_log_line_out_of_an_event() {
let event = "id: 4\nevent: infra\ndata: {\"sequence\":4,\"stream\":\"build\",\"level\":\"info\",\"message\":\"hello\"}\n\n";
assert_eq!(parse_log_event(event).unwrap().message, "hello");
}
#[test]
fn it_should_ignore_an_event_with_no_data() {
assert!(parse_log_event("event: end\n\n").is_none());
}
#[test]
fn it_should_read_a_deployment_out_of_an_infra_event() {
let event = "event: infra\ndata: {\"kind\":\"deployment-status\",\"projectId\":\"prj_1\",\"status\":\"queued\",\"trigger\":\"git-push\",\"serviceName\":\"api\"}\n\n";
assert_eq!(
parse_infra_event(event).unwrap().service_name.unwrap(),
"api"
);
}
#[test]
fn it_should_read_an_event_whose_kind_it_has_never_heard_of() {
let event = "event: infra\ndata: {\"kind\":\"something-new\",\"projectId\":\"prj_1\"}\n\n";
assert_eq!(parse_infra_event(event).unwrap().kind, "something-new");
}
#[test]
fn it_should_ignore_an_event_whose_data_is_not_a_log_line() {
assert!(parse_log_event("data: {\"kind\":\"usage\"}\n\n").is_none());
}
#[test]
#[ignore = "performance budget — the pipeline runs it with --release"]
fn it_should_parse_fifty_thousand_events_within_the_budget() {
let event = "id: 4\nevent: infra\ndata: {\"sequence\":4,\"stream\":\"build\",\"level\":\"info\",\"message\":\"compiling something long enough to be a real line of build output\"}\n\n";
let started = std::time::Instant::now();
for _ in 0..50_000 {
let _ = parse_log_event(event);
}
assert!(started.elapsed() < Duration::from_secs(1));
}
fn events_in(chunks: &[&[u8]]) -> Vec<String> {
let mut pending = Vec::new();
let mut seen = Vec::new();
for chunk in chunks {
pending.extend_from_slice(chunk);
let _ = hand_over_events(&mut pending, &mut |event: &str| {
seen.push(event.to_owned());
ControlFlow::Continue(())
});
}
seen
}
#[test]
fn it_should_hand_over_every_event_in_one_chunk() {
assert_eq!(events_in(&[b"data: 1\n\ndata: 2\n\n"]).len(), 2);
}
#[test]
fn it_should_wait_for_the_rest_of_an_event_cut_by_a_chunk() {
assert_eq!(
events_in(&[b"data: {\"a\":", b"1}\n\n"]),
vec!["data: {\"a\":1}\n\n"]
);
}
#[test]
fn it_should_keep_a_character_cut_in_half_by_a_chunk() {
assert_eq!(
events_in(&[b"data: \xc3", b"\xb1\n\n"]),
vec!["data: ñ\n\n"]
);
}
#[test]
fn it_should_stop_handing_over_once_told_to() {
let mut pending = b"event: end\n\ndata: late\n\n".to_vec();
let mut seen = 0;
let _ = hand_over_events(&mut pending, &mut |_: &str| {
seen += 1;
ControlFlow::Break(())
});
assert_eq!(seen, 1);
}
#[test]
fn it_should_join_a_payload_split_across_several_data_lines() {
let event = "data: {\"sequence\":1,\"message\":\n data: \"split\"}\n\n";
let _ = parse_log_event(event);
}
}