wekan-cli 2.0.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use reqwest::{StatusCode, header::RETRY_AFTER};
use serde::{Deserialize, de::DeserializeOwned};
use serde_json::Value;

use super::{
    ClientError, WekanClient,
    decoding::{decode_value, parse_json},
};

#[cfg(test)]
#[path = "transport/tests.rs"]
mod streaming_tests;

const MAX_RESPONSE_BYTES: usize = 1024 * 1024;

#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct WekanErrorResponse {
    error: Option<WekanErrorCode>,
    reason: Option<String>,
    message: Option<String>,
    error_type: Option<String>,
    is_client_safe: Option<bool>,
    status_code: Option<i64>,
    #[serde(rename = "success")]
    _success: Option<bool>,
    #[serde(rename = "ok")]
    _ok: Option<bool>,
    #[serde(rename = "recorded")]
    _recorded: Option<u64>,
    #[serde(rename = "skipped")]
    _skipped: Option<Vec<String>>,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum WekanErrorCode {
    String(String),
    Number(serde_json::Number),
}

impl WekanErrorCode {
    fn into_string(self) -> String {
        match self {
            Self::String(value) => value,
            Self::Number(value) => value.to_string(),
        }
    }
}

struct ResponseBody {
    status: StatusCode,
    retry_after_seconds: Option<u64>,
    body: Vec<u8>,
}

impl ResponseBody {
    fn require_status(self, expected: StatusCode) -> Result<Vec<u8>, ClientError> {
        if self.status == expected {
            return Ok(self.body);
        }

        let error = match serde_json::from_slice::<Value>(&self.body) {
            Ok(value) => decode_error_response(value, "Wekan error", false)?,
            Err(_) => WekanErrorResponse::default(),
        };
        Err(ClientError::Server {
            status: self.status,
            server_error: error.error.map(WekanErrorCode::into_string),
            server_reason: error.reason,
            retry_after_seconds: self.retry_after_seconds,
        })
    }
}

impl WekanClient {
    pub(super) async fn execute_success_request_with_error<E: From<ClientError>>(
        &self,
        request: reqwest::RequestBuilder,
        decode_error: fn(StatusCode, &[u8]) -> Result<Option<E>, ClientError>,
    ) -> Result<Vec<u8>, E> {
        let response = self.execute_request(request).await?;
        if response.status != StatusCode::OK {
            if let Some(error) = decode_error(response.status, &response.body)? {
                return Err(error);
            }
        }
        response.require_status(StatusCode::OK).map_err(E::from)
    }

    pub(super) async fn execute_success_request(
        &self,
        request: reqwest::RequestBuilder,
    ) -> Result<Vec<u8>, ClientError> {
        self.execute_request(request)
            .await?
            .require_status(StatusCode::OK)
    }

    pub(super) async fn execute_success_request_with_limit(
        &self,
        request: reqwest::RequestBuilder,
        success_limit: usize,
    ) -> Result<Vec<u8>, ClientError> {
        self.execute_request_with_limit(request, success_limit)
            .await?
            .require_status(StatusCode::OK)
    }

    async fn execute_request(
        &self,
        request: reqwest::RequestBuilder,
    ) -> Result<ResponseBody, ClientError> {
        self.execute_request_with_limit(request, MAX_RESPONSE_BYTES)
            .await
    }

    async fn execute_request_with_limit(
        &self,
        request: reqwest::RequestBuilder,
        success_limit: usize,
    ) -> Result<ResponseBody, ClientError> {
        let response = request.send().await.map_err(ClientError::Transport)?;
        let status = response.status();
        if status.is_redirection() {
            return Err(ClientError::UnexpectedRedirect { status });
        }

        let retry_after_seconds = response
            .headers()
            .get(RETRY_AFTER)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| value.parse::<u64>().ok());
        let limit = if status == StatusCode::OK {
            success_limit
        } else {
            MAX_RESPONSE_BYTES
        };
        let body = read_limited_body(response, retry_after_seconds, limit).await?;

        Ok(ResponseBody {
            status,
            retry_after_seconds,
            body,
        })
    }
}

pub(super) fn embedded_error(
    value: &Value,
    http_status: StatusCode,
) -> Result<Option<ClientError>, ClientError> {
    let Some(object) = value.as_object() else {
        return Ok(None);
    };

    if !["statusCode", "error", "reason", "message", "errorType"]
        .iter()
        .any(|field| object.contains_key(*field))
    {
        return Ok(None);
    }

    let error = decode_error_response(
        value.clone(),
        "embedded Wekan error",
        http_status.is_success(),
    )?;
    if let Some(status_code) = error.status_code {
        let wekan_status_code = u16::try_from(status_code).map_err(|_| ClientError::Protocol {
            diagnostic: None,
            message: "the embedded Wekan statusCode was outside the valid range".to_owned(),
            success_status_received: true,
        })?;
        return Ok(Some(ClientError::EmbeddedServer {
            http_status,
            wekan_status_code,
            server_error: error.error.map(WekanErrorCode::into_string),
            server_reason: error.reason,
        }));
    }
    if error.error.is_some()
        || error.reason.is_some()
        || error.message.is_some()
        || error.error_type.is_some()
    {
        return Ok(Some(ClientError::EmbeddedProtocol {
            http_status,
            server_error: error.error.map(WekanErrorCode::into_string),
            server_reason: error.reason,
            server_message: error.message,
            server_error_type: error.error_type,
            server_is_client_safe: error.is_client_safe,
        }));
    }
    Ok(None)
}

fn decode_error_response(
    value: Value,
    message: &str,
    success_status_received: bool,
) -> Result<WekanErrorResponse, ClientError> {
    decode_value(value, message, success_status_received)
}

pub(super) fn decode_success<T: DeserializeOwned>(
    body: &[u8],
    operation: &str,
    require_body: bool,
) -> Result<T, ClientError> {
    if require_body && body.is_empty() {
        return Err(ClientError::Protocol {
            diagnostic: None,
            message: format!("the {operation} response body was empty"),
            success_status_received: true,
        });
    }
    let value = parse_json(body, operation)?;
    if let Some(error) = embedded_error(&value, StatusCode::OK)? {
        return Err(error);
    }
    decode_value(value, operation, true)
}

pub(super) async fn read_limited_body(
    mut response: reqwest::Response,
    retry_after_seconds: Option<u64>,
    limit: usize,
) -> Result<Vec<u8>, ClientError> {
    let status = response.status();
    if response
        .content_length()
        .is_some_and(|length| length > limit as u64)
    {
        return Err(ClientError::ResponseTooLarge {
            limit_bytes: limit,
            status,
            retry_after_seconds,
        });
    }

    let mut body = Vec::new();
    while let Some(chunk) = response
        .chunk()
        .await
        .map_err(|source| ClientError::ResponseBody {
            status,
            retry_after_seconds,
            source,
        })?
    {
        if body.len().saturating_add(chunk.len()) > limit {
            return Err(ClientError::ResponseTooLarge {
                limit_bytes: limit,
                status,
                retry_after_seconds,
            });
        }
        body.extend_from_slice(&chunk);
    }
    Ok(body)
}

pub(super) fn require_success_status(
    status: StatusCode,
    body: Vec<u8>,
    retry_after_seconds: Option<u64>,
) -> Result<(), ClientError> {
    ResponseBody {
        status,
        body,
        retry_after_seconds,
    }
    .require_status(StatusCode::OK)
    .map(|_| ())
}

#[cfg(test)]
mod strict_response_tests {
    use reqwest::StatusCode;
    use serde_json::json;

    use super::{ResponseBody, WekanErrorResponse, embedded_error};
    use crate::client::ClientError;

    #[test]
    fn wekan_error_response_rejects_unmapped_fields() {
        assert!(
            serde_json::from_value::<WekanErrorResponse>(json!({
                "isClientSafe": true,
                "error": "forbidden",
                "reason": "denied",
                "message": "denied [forbidden]",
                "errorType": "Meteor.Error",
                "statusCode": 403,
                "success": false
            }))
            .is_ok()
        );
        assert!(
            serde_json::from_value::<WekanErrorResponse>(json!({
                "error": "forbidden",
                "unexpected": true
            }))
            .is_err()
        );
    }

    #[test]
    fn non_success_json_errors_reject_unmapped_and_invalid_fields() {
        for body in [
            json!({"error": "forbidden", "unexpected": true}),
            json!({"error": ["wrong", "type"]}),
        ] {
            let error = ResponseBody {
                status: StatusCode::BAD_REQUEST,
                retry_after_seconds: None,
                body: serde_json::to_vec(&body).unwrap(),
            }
            .require_status(StatusCode::OK)
            .unwrap_err();

            assert!(matches!(
                error,
                ClientError::Protocol {
                    success_status_received: false,
                    ..
                }
            ));
        }
    }

    #[test]
    fn unreadable_non_success_error_bodies_retain_status_classification() {
        let error = ResponseBody {
            status: StatusCode::BAD_GATEWAY,
            retry_after_seconds: None,
            body: b"not json".to_vec(),
        }
        .require_status(StatusCode::OK)
        .unwrap_err();

        assert!(matches!(
            error,
            ClientError::Server {
                status: StatusCode::BAD_GATEWAY,
                server_error: None,
                server_reason: None,
                ..
            }
        ));
    }

    #[test]
    fn embedded_errors_reject_unmapped_and_invalid_fields() {
        for value in [
            json!({
                "statusCode": 404,
                "error": "not-found",
                "unexpected": true
            }),
            json!({"statusCode": "404", "error": "not-found"}),
        ] {
            assert!(matches!(
                embedded_error(&value, StatusCode::OK),
                Err(ClientError::Protocol {
                    success_status_received: true,
                    ..
                })
            ));
        }

        assert!(
            embedded_error(&json!({"_id": "user-1"}), StatusCode::OK)
                .unwrap()
                .is_none()
        );
    }
}