pub mod attachments;
pub mod custom_fields;
pub mod enumerations;
pub mod files;
pub mod groups;
pub mod issue_categories;
pub mod issue_relations;
pub mod issue_statuses;
pub mod issues;
pub mod my_account;
pub mod news;
pub mod project_memberships;
pub mod projects;
pub mod queries;
pub mod roles;
pub mod search;
#[cfg(test)]
pub mod test_helpers;
pub mod time_entries;
pub mod trackers;
pub mod uploads;
pub mod users;
pub mod versions;
pub mod wiki_pages;
use std::str::from_utf8;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use http::Method;
use std::borrow::Cow;
use reqwest::{blocking::Client, Url};
use tracing::{debug, error, trace};
#[derive(derivative::Derivative)]
#[derivative(Debug)]
pub struct Redmine {
client: Client,
redmine_url: Url,
#[derivative(Debug = "ignore")]
api_key: String,
impersonate_user_id: Option<u64>,
}
fn parse_url<'de, D>(deserializer: D) -> Result<url::Url, D::Error>
where
D: Deserializer<'de>,
{
let buf = String::deserialize(deserializer)?;
url::Url::parse(&buf).map_err(serde::de::Error::custom)
}
#[derive(Debug, serde::Deserialize)]
struct EnvOptions {
redmine_api_key: String,
#[serde(deserialize_with = "parse_url")]
redmine_url: url::Url,
}
#[derive(Debug, Clone)]
pub struct ResponsePage<T> {
pub values: Vec<T>,
pub total_count: u64,
pub offset: u64,
pub limit: u64,
}
impl Redmine {
pub fn new(redmine_url: url::Url, api_key: &str) -> Result<Self, crate::Error> {
#[cfg(not(feature = "rustls-tls"))]
let client = Client::new();
#[cfg(feature = "rustls-tls")]
let client = Client::builder().use_rustls_tls().build()?;
Ok(Self {
client,
redmine_url,
api_key: api_key.to_string(),
impersonate_user_id: None,
})
}
pub fn from_env() -> Result<Self, crate::Error> {
let env_options = envy::from_env::<EnvOptions>()?;
let redmine_url = env_options.redmine_url;
let api_key = env_options.redmine_api_key;
Self::new(redmine_url, &api_key)
}
pub fn impersonate_user(&mut self, id: u64) {
self.impersonate_user_id = Some(id);
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn issue_url(&self, issue_id: u64) -> Url {
let Redmine { redmine_url, .. } = self;
redmine_url.join(&format!("/issues/{}", issue_id)).unwrap()
}
fn rest(
&self,
method: http::Method,
endpoint: &str,
parameters: QueryParams,
mime_type_and_body: Option<(&str, Vec<u8>)>,
) -> Result<(reqwest::StatusCode, bytes::Bytes), crate::Error> {
let Redmine {
client,
redmine_url,
api_key,
impersonate_user_id,
} = self;
let mut url = redmine_url.join(endpoint)?;
parameters.add_to_url(&mut url);
debug!(%url, %method, "Calling redmine");
let req = client
.request(method.clone(), url.clone())
.header("x-redmine-api-key", api_key);
let req = if let Some(user_id) = impersonate_user_id {
req.header("X-Redmine-Switch-User", format!("{}", user_id))
} else {
req
};
let req = if let Some((mime, data)) = mime_type_and_body {
if let Ok(request_body) = from_utf8(&data) {
trace!("Request body (Content-Type: {}):\n{}", mime, request_body);
} else {
trace!(
"Request body (Content-Type: {}) could not be parsed as UTF-8:\n{:?}",
mime,
data
);
}
req.body(data).header("Content-Type", mime)
} else {
req
};
let result = req.send();
if let Err(ref e) = result {
error!(%url, %method, "Redmine send error: {:?}", e);
}
let result = result?;
let status = result.status();
let response_body = result.bytes()?;
match from_utf8(&response_body) {
Ok(response_body) => {
trace!("Response body:\n{}", &response_body);
}
Err(e) => {
trace!(
"Response body that could not be parsed as utf8 because of {}:\n{:?}",
&e,
&response_body
);
}
}
if status.is_client_error() {
error!(%url, %method, "Redmine status error (client error): {:?}", status);
} else if status.is_server_error() {
error!(%url, %method, "Redmine status error (server error): {:?}", status);
}
Ok((status, response_body))
}
pub fn ignore_response_body<E>(&self, endpoint: &E) -> Result<(), crate::Error>
where
E: Endpoint,
{
let method = endpoint.method();
let url = endpoint.endpoint();
let parameters = endpoint.parameters();
let mime_type_and_body = endpoint.body()?;
self.rest(method, &url, parameters, mime_type_and_body)?;
Ok(())
}
pub fn json_response_body<E, R>(&self, endpoint: &E) -> Result<R, crate::Error>
where
E: Endpoint + ReturnsJsonResponse,
R: DeserializeOwned + std::fmt::Debug,
{
let method = endpoint.method();
let url = endpoint.endpoint();
let parameters = endpoint.parameters();
let mime_type_and_body = endpoint.body()?;
let (status, response_body) = self.rest(method, &url, parameters, mime_type_and_body)?;
if response_body.is_empty() {
Err(crate::Error::EmptyResponseBody(status))
} else {
let result = serde_json::from_slice::<R>(&response_body);
if let Ok(ref parsed_response_body) = result {
trace!("Parsed response body:\n{:#?}", parsed_response_body);
}
Ok(result?)
}
}
pub fn json_response_body_page<E, R>(
&self,
endpoint: &E,
offset: u64,
limit: u64,
) -> Result<ResponsePage<R>, crate::Error>
where
E: Endpoint + ReturnsJsonResponse + Pageable,
R: DeserializeOwned + std::fmt::Debug,
{
let method = endpoint.method();
let url = endpoint.endpoint();
let mut parameters = endpoint.parameters();
parameters.push("offset", offset);
parameters.push("limit", limit);
let mime_type_and_body = endpoint.body()?;
let (status, response_body) = self.rest(method, &url, parameters, mime_type_and_body)?;
if response_body.is_empty() {
Err(crate::Error::EmptyResponseBody(status))
} else {
let json_value_response_body: serde_json::Value =
serde_json::from_slice(&response_body)?;
let json_object_response_body = json_value_response_body.as_object();
if let Some(json_object_response_body) = json_object_response_body {
let total_count = json_object_response_body
.get("total_count")
.ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
.as_u64()
.ok_or_else(|| {
crate::Error::PaginationKeyHasWrongType("total_count".to_string())
})?;
let offset = json_object_response_body
.get("offset")
.ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
.as_u64()
.ok_or_else(|| {
crate::Error::PaginationKeyHasWrongType("total_count".to_string())
})?;
let limit = json_object_response_body
.get("limit")
.ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
.as_u64()
.ok_or_else(|| {
crate::Error::PaginationKeyHasWrongType("total_count".to_string())
})?;
let response_wrapper_key = endpoint.response_wrapper_key();
let inner_response_body = json_object_response_body
.get(&response_wrapper_key)
.ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
if let Ok(ref parsed_response_body) = result {
trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
}
Ok(ResponsePage {
values: result?,
total_count,
offset,
limit,
})
} else {
Err(crate::Error::NonObjectResponseBody(status))
}
}
}
pub fn json_response_body_all_pages<E, R>(&self, endpoint: &E) -> Result<Vec<R>, crate::Error>
where
E: Endpoint + ReturnsJsonResponse + Pageable,
R: DeserializeOwned + std::fmt::Debug,
{
let method = endpoint.method();
let url = endpoint.endpoint();
let mut offset = 0;
let limit = 100;
let mut total_results = vec![];
loop {
let mut page_parameters = endpoint.parameters();
page_parameters.push("offset", offset);
page_parameters.push("limit", limit);
let mime_type_and_body = endpoint.body()?;
let (status, response_body) =
self.rest(method.clone(), &url, page_parameters, mime_type_and_body)?;
if response_body.is_empty() {
return Err(crate::Error::EmptyResponseBody(status));
}
let json_value_response_body: serde_json::Value =
serde_json::from_slice(&response_body)?;
let json_object_response_body = json_value_response_body.as_object();
if let Some(json_object_response_body) = json_object_response_body {
let total_count: u64 = json_object_response_body
.get("total_count")
.ok_or_else(|| crate::Error::PaginationKeyMissing("total_count".to_string()))?
.as_u64()
.ok_or_else(|| {
crate::Error::PaginationKeyHasWrongType("total_count".to_string())
})?;
let response_offset: u64 = json_object_response_body
.get("offset")
.ok_or_else(|| crate::Error::PaginationKeyMissing("offset".to_string()))?
.as_u64()
.ok_or_else(|| {
crate::Error::PaginationKeyHasWrongType("total_count".to_string())
})?;
let response_limit: u64 = json_object_response_body
.get("limit")
.ok_or_else(|| crate::Error::PaginationKeyMissing("limit".to_string()))?
.as_u64()
.ok_or_else(|| {
crate::Error::PaginationKeyHasWrongType("total_count".to_string())
})?;
let response_wrapper_key = endpoint.response_wrapper_key();
let inner_response_body = json_object_response_body
.get(&response_wrapper_key)
.ok_or(crate::Error::PaginationKeyMissing(response_wrapper_key))?;
let result = serde_json::from_value::<Vec<R>>(inner_response_body.to_owned());
if let Ok(ref parsed_response_body) = result {
trace!(%total_count, %offset, %limit, "Parsed response body:\n{:?}", parsed_response_body);
}
total_results.extend(result?);
if total_count < (response_offset + response_limit) {
break;
}
offset += limit;
} else {
return Err(crate::Error::NonObjectResponseBody(status));
}
}
Ok(total_results)
}
}
pub trait ParamValue<'a> {
#[allow(clippy::wrong_self_convention)]
fn as_value(&self) -> Cow<'a, str>;
}
impl ParamValue<'static> for bool {
fn as_value(&self) -> Cow<'static, str> {
if *self {
"true".into()
} else {
"false".into()
}
}
}
impl<'a> ParamValue<'a> for &'a str {
fn as_value(&self) -> Cow<'a, str> {
(*self).into()
}
}
impl ParamValue<'static> for String {
fn as_value(&self) -> Cow<'static, str> {
self.clone().into()
}
}
impl<'a> ParamValue<'a> for &'a String {
fn as_value(&self) -> Cow<'a, str> {
(*self).into()
}
}
impl<T> ParamValue<'static> for Vec<T>
where
T: ToString,
{
fn as_value(&self) -> Cow<'static, str> {
self.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(",")
.into()
}
}
impl<'a, T> ParamValue<'a> for &'a Vec<T>
where
T: ToString,
{
fn as_value(&self) -> Cow<'a, str> {
self.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(",")
.into()
}
}
impl<'a> ParamValue<'a> for Cow<'a, str> {
fn as_value(&self) -> Cow<'a, str> {
self.clone()
}
}
impl<'a, 'b: 'a> ParamValue<'a> for &'b Cow<'a, str> {
fn as_value(&self) -> Cow<'a, str> {
(*self).clone()
}
}
impl ParamValue<'static> for u64 {
fn as_value(&self) -> Cow<'static, str> {
format!("{}", self).into()
}
}
impl ParamValue<'static> for f64 {
fn as_value(&self) -> Cow<'static, str> {
format!("{}", self).into()
}
}
impl ParamValue<'static> for time::OffsetDateTime {
fn as_value(&self) -> Cow<'static, str> {
self.format(&time::format_description::well_known::Rfc3339)
.unwrap()
.into()
}
}
impl ParamValue<'static> for time::Date {
fn as_value(&self) -> Cow<'static, str> {
let format = time::format_description::parse("[year]-[month]-[day]").unwrap();
self.format(&format).unwrap().into()
}
}
#[derive(Debug, Default, Clone)]
pub struct QueryParams<'a> {
params: Vec<(Cow<'a, str>, Cow<'a, str>)>,
}
impl<'a> QueryParams<'a> {
pub fn push<'b, K, V>(&mut self, key: K, value: V) -> &mut Self
where
K: Into<Cow<'a, str>>,
V: ParamValue<'b>,
'b: 'a,
{
self.params.push((key.into(), value.as_value()));
self
}
pub fn push_opt<'b, K, V>(&mut self, key: K, value: Option<V>) -> &mut Self
where
K: Into<Cow<'a, str>>,
V: ParamValue<'b>,
'b: 'a,
{
if let Some(value) = value {
self.params.push((key.into(), value.as_value()));
}
self
}
pub fn extend<'b, I, K, V>(&mut self, iter: I) -> &mut Self
where
I: Iterator<Item = (K, V)>,
K: Into<Cow<'a, str>>,
V: ParamValue<'b>,
'b: 'a,
{
self.params
.extend(iter.map(|(key, value)| (key.into(), value.as_value())));
self
}
pub fn add_to_url(&self, url: &mut Url) {
let mut pairs = url.query_pairs_mut();
pairs.extend_pairs(self.params.iter());
}
}
pub trait Endpoint {
fn method(&self) -> Method;
fn endpoint(&self) -> Cow<'static, str>;
fn parameters(&self) -> QueryParams {
QueryParams::default()
}
fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
Ok(None)
}
}
pub trait ReturnsJsonResponse {}
pub trait Pageable {
fn response_wrapper_key(&self) -> String;
}
pub fn deserialize_rfc3339<'de, D>(deserializer: D) -> Result<time::OffsetDateTime, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
.map_err(serde::de::Error::custom)
}
pub fn serialize_rfc3339<S>(t: &time::OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let s = t
.format(&time::format_description::well_known::Rfc3339)
.map_err(serde::ser::Error::custom)?;
s.serialize(serializer)
}
pub fn deserialize_optional_rfc3339<'de, D>(
deserializer: D,
) -> Result<Option<time::OffsetDateTime>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = <Option<String> as Deserialize<'de>>::deserialize(deserializer)?;
if let Some(s) = s {
Ok(Some(
time::OffsetDateTime::parse(&s, &time::format_description::well_known::Rfc3339)
.map_err(serde::de::Error::custom)?,
))
} else {
Ok(None)
}
}
pub fn serialize_optional_rfc3339<S>(
t: &Option<time::OffsetDateTime>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if let Some(t) = t {
let s = t
.format(&time::format_description::well_known::Rfc3339)
.map_err(serde::ser::Error::custom)?;
s.serialize(serializer)
} else {
let n: Option<String> = None;
n.serialize(serializer)
}
}