pingap-plugin 0.13.1

Plugin for pingap
Documentation
// Copyright 2024-2025 Tree xie.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use super::{
    Error, get_hash_key, get_int_conf, get_plugin_factory, get_str_conf,
    get_str_slice_conf,
};
use async_trait::async_trait;
use ctor::ctor;
use http::StatusCode;
use humantime::parse_duration;
use pingap_config::{PluginCategory, PluginConf};
use pingap_core::{
    Ctx, HttpResponse, Plugin, PluginStep, RequestPluginResult, convert_headers,
};
use pingora::proxy::Session;
use std::borrow::Cow;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
use tracing::debug;

type Result<T, E = Error> = std::result::Result<T, E>;

/// MockResponse provides a configurable way to return mock HTTP responses for testing and development.
/// It can match specific paths and introduce artificial delays to simulate various scenarios.
pub struct MockResponse {
    /// The URL path to match against incoming requests.
    /// - If empty string: matches all paths
    /// - If set: must exactly match the request path
    ///   Example: "/api/users" will only mock requests to that exact path
    pub path: String,

    /// Determines at which point in the request lifecycle this mock should execute.
    /// Only supports two phases:
    /// - Request: Early in the cycle, before any upstream processing
    /// - ProxyUpstream: Just before the request would be sent to the upstream server
    ///   This allows testing different failure scenarios and response behaviors
    pub plugin_step: PluginStep,

    /// The pre-configured HTTP response that will be returned when this mock is triggered.
    /// Contains:
    /// - status: HTTP status code (defaults to 200 OK)
    /// - headers: Optional response headers
    /// - body: Response body content
    ///   This response is constructed once during initialization for better performance
    pub resp: HttpResponse,

    /// Optional artificial delay before sending the mock response.
    /// Useful for:
    /// - Testing timeout handling
    /// - Simulating slow network conditions
    /// - Load testing with controlled response times
    ///   Format: Standard Duration (e.g., 500ms, 1s, 1m)
    pub delay: Option<Duration>,

    /// Unique identifier for this plugin instance.
    /// - Generated from the plugin configuration
    /// - Used internally for plugin management
    /// - Not exposed publicly as it's an implementation detail
    hash_value: String,
}

impl MockResponse {
    /// Creates a new mock response handler from a plugin configuration.
    ///
    /// # Parameters
    /// - params: PluginConf containing the following optional fields:
    ///   - path: String - URL path to match
    ///   - status: int - HTTP status code (defaults to 200 OK if not specified)
    ///   - headers: []string - Response headers in "Key: Value" format
    ///   - data: string - Response body content
    ///   - delay: string - Human-readable duration (e.g., "500ms", "1s") to delay response
    ///
    /// # Returns
    /// Result<MockResponse> - Configured mock handler or error if configuration is invalid
    pub fn new(params: &PluginConf) -> Result<Self> {
        debug!(params = params.to_string(), "new mock plugin");

        // Generate unique hash for this configuration
        let hash_value = get_hash_key(params);

        // Extract all configuration parameters
        let path = get_str_conf(params, "path"); // Path to match (empty = match all)
        let status = get_int_conf(params, "status") as u16; // HTTP status code
        let headers = get_str_slice_conf(params, "headers"); // Response headers
        let data = get_str_conf(params, "data"); // Response body

        // Parse delay duration if specified
        // Supports human-readable formats like "500ms", "1s", "1m"
        let delay = get_str_conf(params, "delay");
        let delay = if !delay.is_empty() {
            let d = parse_duration(&delay).map_err(|e| Error::Invalid {
                category: PluginCategory::Mock.to_string(),
                message: e.to_string(),
            })?;
            Some(d)
        } else {
            None
        };

        // Construct the HTTP response with defaults
        let mut resp = HttpResponse {
            status: StatusCode::OK, // Default to 200 OK
            body: data.into(),
            ..Default::default()
        };

        // Override status code if specified
        if status > 0 {
            resp.status =
                StatusCode::from_u16(status).unwrap_or(StatusCode::OK);
        }

        // Add headers if specified
        if !headers.is_empty()
            && let Ok(headers) = convert_headers(&headers)
        {
            resp.headers = Some(headers);
        }

        Ok(MockResponse {
            hash_value,
            resp,
            plugin_step: PluginStep::Request,
            path,
            delay,
        })
    }
}

#[async_trait]
impl Plugin for MockResponse {
    /// Returns the unique identifier for this plugin instance
    #[inline]
    fn config_key(&self) -> Cow<'_, str> {
        Cow::Borrowed(&self.hash_value)
    }

    /// Handles incoming requests and returns mock responses when appropriate.
    ///
    /// # Parameters
    /// - step: Current execution phase
    /// - session: Contains request details including URL path
    /// - _ctx: Ctx context (unused in mock plugin)
    ///
    /// # Returns
    /// - Ok(None) if request should proceed normally
    /// - Ok(Some(HttpResponse)) to return mock response
    /// - Err(...) if processing fails
    async fn handle_request(
        &self,
        step: PluginStep,
        session: &mut Session,
        _ctx: &mut Ctx,
    ) -> pingora::Result<RequestPluginResult> {
        // Only process if we're in the correct execution phase
        if step != self.plugin_step {
            return Ok(RequestPluginResult::Skipped);
        }

        // Check if request path matches our configured path (if any)
        if !self.path.is_empty() && session.req_header().uri.path() != self.path
        {
            return Ok(RequestPluginResult::Skipped);
        }

        // Implement artificial delay if configured
        if let Some(d) = self.delay {
            sleep(d).await;
        }

        // Return our pre-configured mock response
        Ok(RequestPluginResult::Respond(self.resp.clone()))
    }
}

#[ctor]
fn init() {
    get_plugin_factory()
        .register("mock", |params| Ok(Arc::new(MockResponse::new(params)?)));
}

#[cfg(test)]
mod tests {
    use super::*;
    use bytes::Bytes;
    use http::StatusCode;
    use pingap_config::PluginConf;
    use pingap_core::{Ctx, PluginStep};
    use pingora::proxy::Session;
    use pretty_assertions::assert_eq;
    use tokio_test::io::Builder;

    #[test]
    fn test_mock_params() {
        let params = MockResponse::new(
            &toml::from_str::<PluginConf>(
                r###"
path = "/"
status = 500
headers = [
    "Content-Type: application/json"
]
data = "{\"message\":\"Mock Service Unavailable\"}"
"###,
            )
            .unwrap(),
        )
        .unwrap();

        assert_eq!("/", params.path);
    }

    #[tokio::test]
    async fn test_mock_response() {
        let params = toml::from_str::<PluginConf>(
            r###"
path = "/vicanso/pingap"
status = 500
headers = [
    "Content-Type: application/json"
]
data = "{\"message\":\"Mock Service Unavailable\"}"
"###,
        )
        .unwrap();

        let mock = MockResponse::new(&params).unwrap();

        // match request path, get mock response
        let headers = ["Accept-Encoding: gzip"].join("\r\n");
        let input_header =
            format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
        let mock_io = Builder::new().read(input_header.as_bytes()).build();
        let mut session = Session::new_h1(Box::new(mock_io));
        session.read_request().await.unwrap();

        let result = mock
            .handle_request(
                PluginStep::Request,
                &mut session,
                &mut Ctx::default(),
            )
            .await
            .unwrap();

        let RequestPluginResult::Respond(resp) = result else {
            panic!("result is not Respond");
        };
        assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, resp.status);
        assert_eq!(
            r###"Some([("content-type", "application/json")])"###,
            format!("{:?}", resp.headers)
        );
        assert_eq!(
            Bytes::from_static(b"{\"message\":\"Mock Service Unavailable\"}"),
            resp.body
        );

        // not match request path
        let headers = ["Accept-Encoding: gzip"].join("\r\n");
        let input_header =
            format!("GET /vicanso?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
        let mock_io = Builder::new().read(input_header.as_bytes()).build();
        let mut session = Session::new_h1(Box::new(mock_io));
        session.read_request().await.unwrap();

        let result = mock
            .handle_request(
                PluginStep::Request,
                &mut session,
                &mut Ctx::default(),
            )
            .await
            .unwrap();
        assert_eq!(true, result == RequestPluginResult::Skipped);
    }
}