use std::{env, time::Duration};
use reqwest::{
header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT},
Client,
};
use url::Url;
use crate::{
error::{ApiError, Result},
http::HttpInner,
retry::RetryConfig,
};
pub const DEFAULT_BASE_URL: &str = "https://api.cnb.cool";
pub const CNB_API_ACCEPT: &str = "application/vnd.cnb.api+json";
pub const TOKEN_ENV_VAR: &str = "CNB_TOKEN";
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone)]
pub struct ApiClient {
inner: HttpInner,
}
macro_rules! resource_accessor {
(
$(#[$m:meta])*
$feature:literal,
$name:ident,
$module:ident,
$client:ident
) => {
$(#[$m])*
#[cfg(feature = $feature)]
#[cfg_attr(docsrs, doc(cfg(feature = $feature)))]
pub fn $name(&self) -> crate::$module::$client {
crate::$module::$client::new(self.inner.clone())
}
};
}
impl ApiClient {
pub fn new() -> Result<Self> {
ClientBuilder::new().build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub fn http(&self) -> &HttpInner {
&self.inner
}
resource_accessor!( "activities", activities, activities, ActivitiesClient);
resource_accessor!( "ai", ai, ai, AiClient);
resource_accessor!( "artifactory", artifactory, artifactory, ArtifactoryClient);
resource_accessor!( "assets", assets, assets, AssetsClient);
resource_accessor!( "badge", badge, badge, BadgeClient);
resource_accessor!( "build", build, build, BuildClient);
resource_accessor!( "charge", charge, charge, ChargeClient);
resource_accessor!( "event", event, event, EventClient);
resource_accessor!( "followers", followers, followers, FollowersClient);
resource_accessor!( "git", git, git, GitClient);
resource_accessor!( "git_settings", git_settings, git_settings, GitSettingsClient);
resource_accessor!( "issues", issues, issues, IssuesClient);
resource_accessor!( "knowledge_base", knowledge_base, knowledge_base, KnowledgeBaseClient);
resource_accessor!( "members", members, members, MembersClient);
resource_accessor!( "missions", missions, missions, MissionsClient);
resource_accessor!( "organizations", organizations, organizations, OrganizationsClient);
resource_accessor!( "pulls", pulls, pulls, PullsClient);
resource_accessor!( "rank", rank, rank, RankClient);
resource_accessor!( "registries", registries, registries, RegistriesClient);
resource_accessor!( "releases", releases, releases, ReleasesClient);
resource_accessor!( "repo_code_issue", repo_code_issue, repo_code_issue, RepoCodeIssueClient);
resource_accessor!( "repo_contributor", repo_contributor, repo_contributor, RepoContributorClient);
resource_accessor!( "repo_labels", repo_labels, repo_labels, RepoLabelsClient);
resource_accessor!( "repositories", repositories, repositories, RepositoriesClient);
resource_accessor!( "search", search, search, SearchClient);
resource_accessor!( "security", security, security, SecurityClient);
resource_accessor!( "starring", starring, starring, StarringClient);
resource_accessor!( "users", users, users, UsersClient);
resource_accessor!( "wiki", wiki, wiki, WikiClient);
resource_accessor!( "workspace", workspace, workspace, WorkspaceClient);
}
#[derive(Debug, Clone)]
pub struct ClientBuilder {
base_url: String,
token: TokenSource,
timeout: Option<Duration>,
user_agent: Option<String>,
retry: RetryConfig,
extra_headers: Option<HeaderMap>,
}
#[derive(Debug, Clone)]
enum TokenSource {
Explicit(String),
Env,
None,
}
impl Default for ClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl ClientBuilder {
pub fn new() -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_string(),
token: TokenSource::Env,
timeout: Some(DEFAULT_TIMEOUT),
user_agent: None,
retry: RetryConfig::default(),
extra_headers: None,
}
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn token(mut self, token: impl Into<String>) -> Self {
self.token = TokenSource::Explicit(token.into());
self
}
pub fn no_token(mut self) -> Self {
self.token = TokenSource::None;
self
}
pub fn timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
self.timeout = timeout.into();
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
pub fn retry(mut self, retry: RetryConfig) -> Self {
self.retry = retry;
self
}
pub fn default_header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
let headers = self.extra_headers.get_or_insert_with(HeaderMap::new);
if let (Ok(name), Ok(value)) = (
reqwest::header::HeaderName::from_bytes(name.as_ref().as_bytes()),
HeaderValue::from_str(value.as_ref()),
) {
headers.insert(name, value);
}
self
}
pub fn build(self) -> Result<ApiClient> {
let token: Option<String> = match self.token {
TokenSource::Explicit(t) if !t.is_empty() => Some(t),
TokenSource::Explicit(_) => None,
TokenSource::None => None,
TokenSource::Env => match env::var(TOKEN_ENV_VAR) {
Ok(v) if !v.is_empty() => Some(v),
Ok(_) => None,
Err(env::VarError::NotPresent) => None,
Err(env::VarError::NotUnicode(_)) => {
return Err(ApiError::EnvVar(format!(
"{TOKEN_ENV_VAR} is not valid UTF-8"
)));
}
},
};
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static(CNB_API_ACCEPT));
if let Some(token) = token {
let bearer = format!("Bearer {token}");
let mut hv = HeaderValue::from_str(&bearer)
.map_err(|_| ApiError::EnvVar("invalid characters in CNB token".into()))?;
hv.set_sensitive(true);
headers.insert(AUTHORIZATION, hv);
}
let ua = self
.user_agent
.unwrap_or_else(|| format!("cnb-rs/{}", env!("CARGO_PKG_VERSION")));
headers.insert(
USER_AGENT,
HeaderValue::from_str(&ua)
.map_err(|_| ApiError::EnvVar("invalid characters in user agent".into()))?,
);
if let Some(extras) = self.extra_headers {
headers.extend(extras);
}
let mut builder = Client::builder().default_headers(headers);
if let Some(t) = self.timeout {
builder = builder.timeout(t);
}
let client = builder.build().map_err(ApiError::from)?;
let raw = if self.base_url.ends_with('/') {
self.base_url.clone()
} else {
format!("{}/", self.base_url)
};
let base_url = Url::parse(&raw).map_err(ApiError::from)?;
Ok(ApiClient {
inner: HttpInner::new(base_url, client, self.retry),
})
}
}