use std::fmt;
use std::sync::{Arc, OnceLock};
use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, VariantNames};
use tracing::{debug, trace};
use url::Url;
use crate::traits::{Merge, MergeOption, WebClient, WebService};
use crate::Error;
use super::{Client, ClientParameters, ServiceKind};
pub mod get;
pub mod search;
#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq)]
pub struct Authentication {
pub key: Option<String>,
pub user: Option<String>,
pub password: Option<String>,
}
impl Merge for Authentication {
fn merge(&mut self, other: Self) {
*self = Self {
key: self.key.merge(other.key),
user: self.user.merge(other.user),
password: self.password.merge(other.password),
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
pub struct Config {
base: Url,
pub name: String,
#[serde(skip)]
web_base: OnceLock<Option<Url>>,
#[serde(flatten)]
pub auth: Authentication,
#[serde(flatten)]
pub client: ClientParameters,
pub max_search_results: Option<usize>,
}
impl Config {
pub fn new(base: &str) -> crate::Result<Self> {
let base = base.trim_end_matches('/');
let base = Url::parse(&format!("{base}/"))
.map_err(|e| Error::InvalidValue(format!("invalid URL: {base}: {e}")))?;
Ok(Self {
base,
web_base: Default::default(),
name: Default::default(),
auth: Default::default(),
client: Default::default(),
max_search_results: Default::default(),
})
}
fn web_base(&self) -> &Url {
self.web_base
.get_or_init(|| {
if let Some((base, _project)) = self.base.as_str().split_once("/projects/") {
if let Ok(url) = Url::parse(base) {
return Some(url);
}
}
None
})
.as_ref()
.unwrap_or(&self.base)
}
fn max_search_results(&self) -> usize {
match self.max_search_results.unwrap_or_default() {
0 => 100,
n => n,
}
}
}
impl WebClient for Config {
fn base(&self) -> &Url {
&self.base
}
fn kind(&self) -> ServiceKind {
ServiceKind::Redmine
}
fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug)]
struct Service {
config: Config,
_cache: ServiceCache,
client: Client,
}
#[derive(Debug)]
pub struct ServiceBuilder(Config);
impl ServiceBuilder {
pub fn auth(mut self, value: Authentication) -> Self {
self.0.auth.merge(value);
self
}
pub fn client(mut self, value: ClientParameters) -> Self {
self.0.client.merge(value);
self
}
pub fn build(self) -> crate::Result<Redmine> {
let client = self.0.client.build()?;
Ok(Redmine(Arc::new(Service {
config: self.0,
_cache: Default::default(),
client,
})))
}
}
#[derive(Debug, Clone)]
pub struct Redmine(Arc<Service>);
impl PartialEq for Redmine {
fn eq(&self, other: &Self) -> bool {
self.config() == other.config()
}
}
impl fmt::Display for Redmine {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} -- {}", self.kind(), self.base())
}
}
impl Redmine {
pub fn new(base: &str) -> crate::Result<Self> {
Self::builder(base)?.build()
}
pub fn builder(base: &str) -> crate::Result<ServiceBuilder> {
Ok(ServiceBuilder(Config::new(base)?))
}
pub fn config_builder(
config: &crate::config::Config,
name: Option<&str>,
) -> crate::Result<ServiceBuilder> {
let config = config
.get_kind(ServiceKind::Redmine, name)?
.into_redmine()
.unwrap();
Ok(ServiceBuilder(config))
}
pub fn config(&self) -> &Config {
&self.0.config
}
pub fn client(&self) -> &Client {
&self.0.client
}
pub fn item_url<I: fmt::Display>(&self, id: I) -> String {
let base = self.config().web_base().as_str().trim_end_matches('/');
format!("{base}/issues/{id}")
}
pub fn get<I>(&self, ids: I) -> get::Request
where
I: IntoIterator<Item = u64>,
{
get::Request::new(self, ids)
}
pub fn search(&self) -> search::Request {
search::Request::new(self)
}
}
impl WebService for Redmine {
const API_VERSION: &'static str = "5.1";
type Response = serde_json::Value;
fn inject_auth(
&self,
request: RequestBuilder,
required: bool,
) -> crate::Result<RequestBuilder> {
let auth = &self.config().auth;
if let Some(key) = auth.key.as_ref() {
Ok(request.header("X-Redmine-API-Key", key))
} else if let (Some(user), Some(pass)) = (&auth.user, &auth.password) {
Ok(request.basic_auth(user, Some(pass)))
} else if !required {
Ok(request)
} else {
Err(Error::Auth)
}
}
async fn parse_response(&self, response: reqwest::Response) -> crate::Result<Self::Response> {
trace!("{response:?}");
match response.error_for_status_ref() {
Ok(_) => {
let mut data: serde_json::Value = response.json().await?;
debug!(
"response data:\n{}",
serde_json::to_string_pretty(&data).unwrap()
);
let errors = data["errors"].take();
if !errors.is_null() {
let errors: Vec<_> = serde_json::from_value(errors).map_err(|e| {
Error::InvalidValue(format!("failed deserializing errors: {e}"))
})?;
let error = errors.into_iter().next().unwrap();
Err(Error::Redmine(error))
} else {
Ok(data)
}
}
Err(e) => {
if let Ok(mut data) = response.json::<serde_json::Value>().await {
debug!("error:\n{}", serde_json::to_string_pretty(&data).unwrap());
let errors = data["errors"].take();
if !errors.is_null() {
let errors: Vec<_> = serde_json::from_value(errors).map_err(|e| {
Error::InvalidValue(format!("failed deserializing errors: {e}"))
})?;
let error = errors.into_iter().next().unwrap();
return Err(Error::Redmine(error));
}
}
Err(e.into())
}
}
}
}
impl WebClient for Redmine {
fn base(&self) -> &Url {
self.config().base()
}
fn kind(&self) -> ServiceKind {
self.config().kind()
}
fn name(&self) -> &str {
self.config().name()
}
}
#[derive(Display, EnumString, VariantNames, Debug, Eq, PartialEq, Hash, Clone, Copy)]
#[strum(serialize_all = "kebab-case")]
pub enum IssueField {
Assignee,
Author,
Closed,
Created,
Id,
Priority,
Status,
Subject,
Tracker,
Updated,
}
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
pub struct ServiceCache {}