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 futures::future::FutureExt as _;
use std::str::from_utf8;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::DeserializeOwned;
use reqwest::Method;
use std::borrow::Cow;
use reqwest::Url;
use tracing::{debug, error, trace};
#[derive(derive_more::Debug)]
pub struct Redmine {
client: reqwest::blocking::Client,
redmine_url: Url,
#[debug(skip)]
api_key: String,
impersonate_user_id: Option<u64>,
}
#[derive(derive_more::Debug)]
pub struct RedmineAsync {
client: reqwest::Client,
redmine_url: Url,
#[debug(skip)]
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, Clone, 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(
client: reqwest::blocking::Client,
redmine_url: url::Url,
api_key: &str,
) -> Result<Self, crate::Error> {
Ok(Self {
client,
redmine_url,
api_key: api_key.to_string(),
impersonate_user_id: None,
})
}
pub fn from_env(client: reqwest::blocking::Client) -> 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(client, redmine_url, &api_key)
}
pub fn impersonate_user(&mut self, id: u64) {
self.impersonate_user_id = Some(id);
}
#[must_use]
pub fn redmine_url(&self) -> &Url {
&self.redmine_url
}
#[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: reqwest::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): {:?} response: {:?}", status, from_utf8(&response_body));
return Err(crate::Error::HttpErrorResponse(status));
} else if status.is_server_error() {
error!(%url, %method, "Redmine status error (server error): {:?} response: {:?}", status, from_utf8(&response_body));
return Err(crate::Error::HttpErrorResponse(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 + NoPagination,
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 fn json_response_body_all_pages_iter<'a, 'e, 'i, E, R>(
&'a self,
endpoint: &'e E,
) -> AllPages<'i, E, R>
where
E: Endpoint + ReturnsJsonResponse + Pageable,
R: DeserializeOwned + std::fmt::Debug,
'a: 'i,
'e: 'i,
{
AllPages::new(self, endpoint)
}
}
impl RedmineAsync {
pub fn new(
client: reqwest::Client,
redmine_url: url::Url,
api_key: &str,
) -> Result<std::sync::Arc<Self>, crate::Error> {
Ok(std::sync::Arc::new(Self {
client,
redmine_url,
api_key: api_key.to_string(),
impersonate_user_id: None,
}))
}
pub fn from_env(client: reqwest::Client) -> Result<std::sync::Arc<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(client, redmine_url, &api_key)
}
pub fn impersonate_user(&mut self, id: u64) {
self.impersonate_user_id = Some(id);
}
#[must_use]
pub fn redmine_url(&self) -> &Url {
&self.redmine_url
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn issue_url(&self, issue_id: u64) -> Url {
let RedmineAsync { redmine_url, .. } = self;
redmine_url.join(&format!("/issues/{issue_id}")).unwrap()
}
async fn rest(
self: std::sync::Arc<Self>,
method: reqwest::Method,
endpoint: &str,
parameters: QueryParams<'_>,
mime_type_and_body: Option<(&str, Vec<u8>)>,
) -> Result<(reqwest::StatusCode, bytes::Bytes), crate::Error> {
let RedmineAsync {
client,
redmine_url,
api_key,
impersonate_user_id,
} = self.as_ref();
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().await;
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().await?;
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): {:?} response: {:?}", status, from_utf8(&response_body));
} else if status.is_server_error() {
error!(%url, %method, "Redmine status error (server error): {:?} response: {:?}", status, from_utf8(&response_body));
}
Ok((status, response_body))
}
pub async fn ignore_response_body<E>(
self: std::sync::Arc<Self>,
endpoint: impl EndpointParameter<E>,
) -> Result<(), crate::Error>
where
E: Endpoint,
{
let endpoint: std::sync::Arc<E> = endpoint.into_arc();
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)
.await?;
Ok(())
}
pub async fn json_response_body<E, R>(
self: std::sync::Arc<Self>,
endpoint: impl EndpointParameter<E>,
) -> Result<R, crate::Error>
where
E: Endpoint + ReturnsJsonResponse + NoPagination,
R: DeserializeOwned + std::fmt::Debug,
{
let endpoint: std::sync::Arc<E> = endpoint.into_arc();
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)
.await?;
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 async fn json_response_body_page<E, R>(
self: std::sync::Arc<Self>,
endpoint: impl EndpointParameter<E>,
offset: u64,
limit: u64,
) -> Result<ResponsePage<R>, crate::Error>
where
E: Endpoint + ReturnsJsonResponse + Pageable,
R: DeserializeOwned + std::fmt::Debug,
{
let endpoint: std::sync::Arc<E> = endpoint.into_arc();
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)
.await?;
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 async fn json_response_body_all_pages<E, R>(
self: std::sync::Arc<Self>,
endpoint: impl EndpointParameter<E>,
) -> Result<Vec<R>, crate::Error>
where
E: Endpoint + ReturnsJsonResponse + Pageable,
R: DeserializeOwned + std::fmt::Debug,
{
let endpoint: std::sync::Arc<E> = endpoint.into_arc();
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
.clone()
.rest(method.clone(), &url, page_parameters, mime_type_and_body)
.await?;
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 fn json_response_body_all_pages_stream<E, R>(
self: std::sync::Arc<Self>,
endpoint: impl EndpointParameter<E>,
) -> AllPagesAsync<E, R>
where
E: Endpoint + ReturnsJsonResponse + Pageable,
R: DeserializeOwned + std::fmt::Debug,
{
let endpoint: std::sync::Arc<E> = endpoint.into_arc();
AllPagesAsync::new(self, endpoint)
}
}
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, Clone)]
pub enum DateTimeFilterPast {
ExactMatch(time::OffsetDateTime),
Range(time::OffsetDateTime, time::OffsetDateTime),
LessThanOrEqual(time::OffsetDateTime),
GreaterThanOrEqual(time::OffsetDateTime),
LessThanDaysAgo(u32),
MoreThanDaysAgo(u32),
WithinPastDays(u32),
ExactDaysAgo(u32),
Today,
Yesterday,
ThisWeek,
LastWeek,
LastTwoWeeks,
ThisMonth,
LastMonth,
ThisYear,
Unset,
Any,
}
impl std::fmt::Display for DateTimeFilterPast {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let format =
time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z");
match self {
DateTimeFilterPast::ExactMatch(v) => {
write!(
f,
"{}",
v.format(&format).expect(
"Error formatting OffsetDateTime in DateTimeFilterPast::ExactMatch"
)
)
}
DateTimeFilterPast::Range(v_start, v_end) => {
write!(
f,
"><{}|{}",
v_start.format(&format).expect(
"Error formatting first OffsetDateTime in DateTimeFilterPast::Range"
),
v_end.format(&format).expect(
"Error formatting second OffsetDateTime in DateTimeFilterPast::Range"
),
)
}
DateTimeFilterPast::LessThanOrEqual(v) => {
write!(
f,
"<={}",
v.format(&format).expect(
"Error formatting OffsetDateTime in DateTimeFilterPast::LessThanOrEqual"
)
)
}
DateTimeFilterPast::GreaterThanOrEqual(v) => {
write!(
f,
">={}",
v.format(&format).expect(
"Error formatting OffsetDateTime in DateTimeFilterPast::GreaterThanOrEqual"
)
)
}
DateTimeFilterPast::LessThanDaysAgo(d) => {
write!(f, ">t-{}", d)
}
DateTimeFilterPast::MoreThanDaysAgo(d) => {
write!(f, "<t-{}", d)
}
DateTimeFilterPast::WithinPastDays(d) => {
write!(f, "><t-{}", d)
}
DateTimeFilterPast::ExactDaysAgo(d) => {
write!(f, "t-{}", d)
}
DateTimeFilterPast::Today => {
write!(f, "t")
}
DateTimeFilterPast::Yesterday => {
write!(f, "ld")
}
DateTimeFilterPast::ThisWeek => {
write!(f, "w")
}
DateTimeFilterPast::LastWeek => {
write!(f, "lw")
}
DateTimeFilterPast::LastTwoWeeks => {
write!(f, "l2w")
}
DateTimeFilterPast::ThisMonth => {
write!(f, "m")
}
DateTimeFilterPast::LastMonth => {
write!(f, "lm")
}
DateTimeFilterPast::ThisYear => {
write!(f, "y")
}
DateTimeFilterPast::Unset => {
write!(f, "!*")
}
DateTimeFilterPast::Any => {
write!(f, "*")
}
}
}
}
#[derive(Debug, Clone)]
pub enum StringFieldFilter {
ExactMatch(String),
SubStringMatch(String),
}
impl std::fmt::Display for StringFieldFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StringFieldFilter::ExactMatch(s) => {
write!(f, "{s}")
}
StringFieldFilter::SubStringMatch(s) => {
write!(f, "~{s}")
}
}
}
}
#[derive(Debug, Clone)]
pub struct CustomFieldFilter {
pub id: u64,
pub value: StringFieldFilter,
}
#[derive(Debug, Clone)]
pub enum FloatFilter {
ExactMatch(f64),
Range(f64, f64),
LessThanOrEqual(f64),
GreaterThanOrEqual(f64),
Any,
None,
}
impl std::fmt::Display for FloatFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FloatFilter::ExactMatch(v) => write!(f, "{}", v),
FloatFilter::Range(v_start, v_end) => write!(f, "><{}|{}", v_start, v_end),
FloatFilter::LessThanOrEqual(v) => write!(f, "<={}", v),
FloatFilter::GreaterThanOrEqual(v) => write!(f, ">={}", v),
FloatFilter::Any => write!(f, "*"),
FloatFilter::None => write!(f, "!*"),
}
}
}
#[derive(Debug, Clone)]
pub enum IntegerFilter {
ExactMatch(u64),
Range(u64, u64),
LessThanOrEqual(u64),
GreaterThanOrEqual(u64),
Any,
None,
}
impl std::fmt::Display for IntegerFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IntegerFilter::ExactMatch(v) => write!(f, "{}", v),
IntegerFilter::Range(v_start, v_end) => write!(f, "><{}|{}", v_start, v_end),
IntegerFilter::LessThanOrEqual(v) => write!(f, "<={}", v),
IntegerFilter::GreaterThanOrEqual(v) => write!(f, ">={}", v),
IntegerFilter::Any => write!(f, "*"),
IntegerFilter::None => write!(f, "!*"),
}
}
}
#[derive(Debug, Clone)]
pub enum TrackerFilter {
Any,
None,
TheseTrackers(Vec<u64>),
NotTheseTrackers(Vec<u64>),
}
impl std::fmt::Display for TrackerFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrackerFilter::Any => write!(f, "*"),
TrackerFilter::None => write!(f, "!*"),
TrackerFilter::TheseTrackers(ids) => {
let s: String = ids
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(",");
write!(f, "{s}")
}
TrackerFilter::NotTheseTrackers(ids) => {
let s: String = ids
.iter()
.map(|e| format!("!{e}"))
.collect::<Vec<_>>()
.join(",");
write!(f, "{s}")
}
}
}
}
#[derive(Debug, Clone)]
pub enum ActivityFilter {
Any,
None,
TheseActivities(Vec<u64>),
NotTheseActivities(Vec<u64>),
}
impl std::fmt::Display for ActivityFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ActivityFilter::Any => write!(f, "*"),
ActivityFilter::None => write!(f, "!*"),
ActivityFilter::TheseActivities(ids) => {
let s: String = ids
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(",");
write!(f, "{s}")
}
ActivityFilter::NotTheseActivities(ids) => {
let s: String = ids
.iter()
.map(|e| format!("!{e}"))
.collect::<Vec<_>>()
.join(",");
write!(f, "{s}")
}
}
}
}
#[derive(Debug, Clone)]
pub enum VersionFilter {
Any,
None,
TheseVersions(Vec<u64>),
NotTheseVersions(Vec<u64>),
}
impl std::fmt::Display for VersionFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VersionFilter::Any => write!(f, "*"),
VersionFilter::None => write!(f, "!*"),
VersionFilter::TheseVersions(ids) => {
let s: String = ids
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(",");
write!(f, "{s}")
}
VersionFilter::NotTheseVersions(ids) => {
let s: String = ids
.iter()
.map(|e| format!("!{e}"))
.collect::<Vec<_>>()
.join(",");
write!(f, "{s}")
}
}
}
}
#[derive(Debug, Clone)]
pub enum DateFilter {
ExactMatch(time::Date),
Range(time::Date, time::Date),
LessThanOrEqual(time::Date),
GreaterThanOrEqual(time::Date),
LessThanDaysAgo(u32),
MoreThanDaysAgo(u32),
WithinPastDays(u32),
ExactDaysAgo(u32),
InLessThanDays(u32),
InMoreThanDays(u32),
WithinFutureDays(u32),
InExactDays(u32),
Today,
Yesterday,
Tomorrow,
ThisWeek,
LastWeek,
LastTwoWeeks,
NextWeek,
ThisMonth,
LastMonth,
NextMonth,
ThisYear,
Unset,
Any,
}
impl std::fmt::Display for DateFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let format = time::macros::format_description!("[year]-[month]-[day]");
match self {
DateFilter::ExactMatch(v) => {
write!(
f,
"{}",
v.format(&format)
.expect("Error formatting Date in DateFilter::ExactMatch")
)
}
DateFilter::Range(v_start, v_end) => {
write!(
f,
"><{}|{}",
v_start
.format(&format)
.expect("Error formatting first Date in DateFilter::Range"),
v_end
.format(&format)
.expect("Error formatting second Date in DateFilter::Range"),
)
}
DateFilter::LessThanOrEqual(v) => {
write!(
f,
"<={}",
v.format(&format)
.expect("Error formatting Date in DateFilter::LessThanOrEqual")
)
}
DateFilter::GreaterThanOrEqual(v) => {
write!(
f,
">={}",
v.format(&format)
.expect("Error formatting Date in DateFilter::GreaterThanOrEqual")
)
}
DateFilter::LessThanDaysAgo(d) => {
write!(f, ">t-{}", d)
}
DateFilter::MoreThanDaysAgo(d) => {
write!(f, "<t-{}", d)
}
DateFilter::WithinPastDays(d) => {
write!(f, "><t-{}", d)
}
DateFilter::ExactDaysAgo(d) => {
write!(f, "t-{}", d)
}
DateFilter::InLessThanDays(d) => {
write!(f, "<t+{}", d)
}
DateFilter::InMoreThanDays(d) => {
write!(f, ">t+{}", d)
}
DateFilter::WithinFutureDays(d) => {
write!(f, "><t+{}", d)
}
DateFilter::InExactDays(d) => {
write!(f, "t+{}", d)
}
DateFilter::Today => {
write!(f, "t")
}
DateFilter::Yesterday => {
write!(f, "ld")
}
DateFilter::Tomorrow => {
write!(f, "nd")
}
DateFilter::ThisWeek => {
write!(f, "w")
}
DateFilter::LastWeek => {
write!(f, "lw")
}
DateFilter::LastTwoWeeks => {
write!(f, "l2w")
}
DateFilter::NextWeek => {
write!(f, "nw")
}
DateFilter::ThisMonth => {
write!(f, "m")
}
DateFilter::LastMonth => {
write!(f, "lm")
}
DateFilter::NextMonth => {
write!(f, "nm")
}
DateFilter::ThisYear => {
write!(f, "y")
}
DateFilter::Unset => {
write!(f, "!*")
}
DateFilter::Any => {
write!(f, "*")
}
}
}
}
#[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 {}
#[diagnostic::on_unimplemented(
message = "{Self} is an endpoint that either returns nothing or requires pagination, use `.ignore_response_body(&endpoint)`, `.json_response_body_page(&endpoint, offset, limit)` or `.json_response_body_all_pages(&endpoint)` instead of `.json_response_body(&endpoint)`"
)]
pub trait NoPagination {}
#[diagnostic::on_unimplemented(
message = "{Self} is an endpoint that does not implement pagination or returns nothing, use `.ignore_response_body(&endpoint)` or `.json_response_body(&endpoint)` instead of `.json_response_body_page(&endpoint, offset, limit)` or `.json_response_body_all_pages(&endpoint)`"
)]
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)
}
}
#[derive(Debug)]
pub struct AllPages<'i, E, R> {
redmine: &'i Redmine,
endpoint: &'i E,
offset: u64,
limit: u64,
total_count: Option<u64>,
yielded: u64,
reversed_rest: Vec<R>,
}
impl<'i, E, R> AllPages<'i, E, R> {
pub fn new(redmine: &'i Redmine, endpoint: &'i E) -> Self {
Self {
redmine,
endpoint,
offset: 0,
limit: 100,
total_count: None,
yielded: 0,
reversed_rest: Vec::new(),
}
}
}
impl<'i, E, R> Iterator for AllPages<'i, E, R>
where
E: Endpoint + ReturnsJsonResponse + Pageable,
R: DeserializeOwned + std::fmt::Debug,
{
type Item = Result<R, crate::Error>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(next) = self.reversed_rest.pop() {
self.yielded += 1;
return Some(Ok(next));
}
if let Some(total_count) = self.total_count
&& self.offset > total_count
{
return None;
}
match self
.redmine
.json_response_body_page(self.endpoint, self.offset, self.limit)
{
Err(e) => Some(Err(e)),
Ok(ResponsePage {
values,
total_count,
offset,
limit,
}) => {
self.total_count = Some(total_count);
self.offset = offset + limit;
self.reversed_rest = values;
self.reversed_rest.reverse();
if let Some(next) = self.reversed_rest.pop() {
self.yielded += 1;
return Some(Ok(next));
}
None
}
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
if let Some(total_count) = self.total_count {
(
self.reversed_rest.len(),
Some((total_count - self.yielded) as usize),
)
} else {
(0, None)
}
}
}
#[pin_project::pin_project]
pub struct AllPagesAsync<E, R> {
#[allow(clippy::type_complexity)]
#[pin]
inner: Option<
std::pin::Pin<Box<dyn futures::Future<Output = Result<ResponsePage<R>, crate::Error>>>>,
>,
redmine: std::sync::Arc<RedmineAsync>,
endpoint: std::sync::Arc<E>,
offset: u64,
limit: u64,
total_count: Option<u64>,
yielded: u64,
reversed_rest: Vec<R>,
}
impl<E, R> std::fmt::Debug for AllPagesAsync<E, R>
where
R: std::fmt::Debug,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AllPagesAsync")
.field("redmine", &self.redmine)
.field("offset", &self.offset)
.field("limit", &self.limit)
.field("total_count", &self.total_count)
.field("yielded", &self.yielded)
.field("reversed_rest", &self.reversed_rest)
.finish()
}
}
impl<E, R> AllPagesAsync<E, R> {
pub fn new(redmine: std::sync::Arc<RedmineAsync>, endpoint: std::sync::Arc<E>) -> Self {
Self {
inner: None,
redmine,
endpoint,
offset: 0,
limit: 100,
total_count: None,
yielded: 0,
reversed_rest: Vec::new(),
}
}
}
impl<E, R> futures::stream::Stream for AllPagesAsync<E, R>
where
E: Endpoint + ReturnsJsonResponse + Pageable + 'static,
R: DeserializeOwned + std::fmt::Debug + 'static,
{
type Item = Result<R, crate::Error>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
ctx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
if let Some(mut inner) = self.inner.take() {
match inner.as_mut().poll(ctx) {
std::task::Poll::Pending => {
self.inner = Some(inner);
std::task::Poll::Pending
}
std::task::Poll::Ready(Err(e)) => std::task::Poll::Ready(Some(Err(e))),
std::task::Poll::Ready(Ok(ResponsePage {
values,
total_count,
offset,
limit,
})) => {
self.total_count = Some(total_count);
self.offset = offset + limit;
self.reversed_rest = values;
self.reversed_rest.reverse();
if let Some(next) = self.reversed_rest.pop() {
self.yielded += 1;
return std::task::Poll::Ready(Some(Ok(next)));
}
std::task::Poll::Ready(None)
}
}
} else {
if let Some(next) = self.reversed_rest.pop() {
self.yielded += 1;
return std::task::Poll::Ready(Some(Ok(next)));
}
if let Some(total_count) = self.total_count
&& self.offset > total_count
{
return std::task::Poll::Ready(None);
}
self.inner = Some(
self.redmine
.clone()
.json_response_body_page(self.endpoint.clone(), self.offset, self.limit)
.boxed_local(),
);
self.poll_next(ctx)
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
if let Some(total_count) = self.total_count {
(
self.reversed_rest.len(),
Some((total_count - self.yielded) as usize),
)
} else {
(0, None)
}
}
}
pub trait EndpointParameter<E> {
fn into_arc(self) -> std::sync::Arc<E>;
}
impl<E> EndpointParameter<E> for &E
where
E: Clone,
{
fn into_arc(self) -> std::sync::Arc<E> {
std::sync::Arc::new(self.to_owned())
}
}
impl<E> EndpointParameter<E> for std::sync::Arc<E> {
fn into_arc(self) -> std::sync::Arc<E> {
self
}
}