mockforge-http 0.3.111

HTTP/REST protocol support for MockForge
Documentation
//! File generation service for MockForge
//!
//! This module provides functionality to generate mock files (PDF, CSV, JSON)
//! based on route and request context for download URLs in API responses.

use chrono::Utc;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;

/// Types of files that can be generated
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
    /// PDF file
    Pdf,
    /// CSV file
    Csv,
    /// JSON file
    Json,
    /// EPCIS XML file
    Epcis,
}

impl FileType {
    /// Get file extension for this type
    pub fn extension(&self) -> &'static str {
        match self {
            FileType::Pdf => "pdf",
            FileType::Csv => "csv",
            FileType::Json => "json",
            FileType::Epcis => "xml",
        }
    }

    /// Get MIME type for this file type
    pub fn mime_type(&self) -> &'static str {
        match self {
            FileType::Pdf => "application/pdf",
            FileType::Csv => "text/csv",
            FileType::Json => "application/json",
            FileType::Epcis => "application/xml",
        }
    }

    /// Parse file type from string, returning None for unknown types
    pub fn parse(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "pdf" => Some(FileType::Pdf),
            "csv" => Some(FileType::Csv),
            "json" => Some(FileType::Json),
            "epcis" | "xml" => Some(FileType::Epcis),
            _ => None,
        }
    }
}

impl std::str::FromStr for FileType {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::parse(s).ok_or_else(|| format!("Unknown file type: {s}"))
    }
}

/// File generation context
#[derive(Debug, Clone)]
pub struct GenerationContext {
    /// Route identifier (e.g., "labels", "invoices", "provenance")
    pub route_id: String,
    /// File type to generate
    pub file_type: FileType,
    /// Request ID or other identifier
    pub request_id: Option<String>,
    /// Additional metadata
    pub metadata: serde_json::Value,
}

/// File generation service
#[derive(Debug, Clone)]
pub struct FileGenerator {
    /// Base directory for generated files
    base_dir: PathBuf,
    /// File generation statistics
    stats: Arc<RwLock<GenerationStats>>,
}

/// Statistics for file generation
#[derive(Debug, Default)]
struct GenerationStats {
    files_generated: u64,
    total_bytes: u64,
}

impl FileGenerator {
    /// Create a new file generator
    pub fn new(base_dir: impl AsRef<Path>) -> Self {
        let base_dir = base_dir.as_ref().to_path_buf();
        Self {
            base_dir,
            stats: Arc::new(RwLock::new(GenerationStats::default())),
        }
    }

    /// Generate a file based on context
    pub async fn generate_file(&self, context: GenerationContext) -> anyhow::Result<PathBuf> {
        // Create directory structure: mock-files/{route_id}/
        let route_dir = self.base_dir.join(&context.route_id);
        tokio::fs::create_dir_all(&route_dir).await?;

        // Generate unique filename
        let filename = format!(
            "{}_{}.{}",
            context.request_id.as_ref().unwrap_or(&Uuid::new_v4().to_string()),
            Utc::now().timestamp(),
            context.file_type.extension()
        );
        let file_path = route_dir.join(&filename);

        // Generate file content based on type
        let content = match context.file_type {
            FileType::Pdf => self.generate_pdf(&context)?,
            FileType::Csv => self.generate_csv(&context)?,
            FileType::Json => self.generate_json(&context)?,
            FileType::Epcis => self.generate_epcis(&context)?,
        };

        // Write file
        tokio::fs::write(&file_path, content).await?;

        // Update statistics
        {
            let mut stats = self.stats.write().await;
            stats.files_generated += 1;
            stats.total_bytes += file_path.metadata()?.len();
        }

        tracing::debug!("Generated file: {:?}", file_path);
        Ok(file_path)
    }

    /// Generate PDF content (simple text-based PDF)
    fn generate_pdf(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
        // For mock purposes, generate a simple PDF with minimal structure
        // This is a minimal PDF that displays text
        let _title = format!("Mock {} Document", context.route_id);
        let content = format!(
            "MockForge Generated Document\n\
            Route: {}\n\
            Generated: {}\n\
            Request ID: {}\n\
            \n\
            This is a mock document generated by MockForge.\n\
            In a real implementation, this would contain actual data.\n",
            context.route_id,
            Utc::now().to_rfc3339(),
            context.request_id.as_deref().unwrap_or("N/A")
        );

        // Generate minimal PDF structure
        // PDF format: %PDF-1.4\n...\n%%EOF
        let pdf_content = format!(
            "%PDF-1.4\n\
            1 0 obj\n\
            << /Type /Catalog /Pages 2 0 R >>\n\
            endobj\n\
            2 0 obj\n\
            << /Type /Pages /Kids [3 0 R] /Count 1 >>\n\
            endobj\n\
            3 0 obj\n\
            << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>\n\
            endobj\n\
            4 0 obj\n\
            << /Length {} >>\n\
            stream\n\
            BT\n\
            /F1 12 Tf\n\
            100 700 Td\n\
            ({}) Tj\n\
            ET\n\
            endstream\n\
            endobj\n\
            xref\n\
            0 5\n\
            0000000000 65535 f\n\
            0000000009 00000 n\n\
            0000000058 00000 n\n\
            0000000115 00000 n\n\
            0000000215 00000 n\n\
            trailer\n\
            << /Size 5 /Root 1 0 R >>\n\
            startxref\n\
            {}\n\
            %%EOF",
            content.len(),
            content.replace("(", "\\(").replace(")", "\\)"),
            content.len() + 200 // Approximate xref offset based on content position
        );

        Ok(pdf_content.into_bytes())
    }

    /// Generate CSV content
    fn generate_csv(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
        let mut csv = String::new();

        // Add header
        csv.push_str("ID,Route,Generated At,Request ID\n");

        // Add data row
        csv.push_str(&format!(
            "{},{},{},{}\n",
            Uuid::new_v4(),
            context.route_id,
            Utc::now().to_rfc3339(),
            context.request_id.as_deref().unwrap_or("N/A")
        ));

        // If metadata contains array data, add it
        if let Some(metadata_array) = context.metadata.as_array() {
            for item in metadata_array {
                if let Some(obj) = item.as_object() {
                    let row: Vec<String> =
                        obj.values().map(|v| v.to_string().trim_matches('"').to_string()).collect();
                    csv.push_str(&row.join(","));
                    csv.push('\n');
                }
            }
        }

        Ok(csv.into_bytes())
    }

    /// Generate JSON content
    fn generate_json(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
        let json = serde_json::json!({
            "route_id": context.route_id,
            "generated_at": Utc::now().to_rfc3339(),
            "request_id": context.request_id,
            "metadata": context.metadata,
            "mockforge_version": env!("CARGO_PKG_VERSION"),
        });

        Ok(serde_json::to_vec_pretty(&json)?)
    }

    /// Generate EPCIS XML content
    fn generate_epcis(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
        let xml = format!(
            r#"<?xml version="1.0" encoding="UTF-8"?>
<epcis:EPCISDocument xmlns:epcis="urn:epcglobal:epcis:xsd:1" xmlns:cbv="urn:epcglobal:cbv:mda" xmlns:gdst="https://ref.gs1.org/cbv/">
  <EPCISHeader>
    <epcis:version>2.0</epcis:version>
  </EPCISHeader>
  <EPCISBody>
    <EventList>
      <ObjectEvent>
        <eventTime>{}</eventTime>
        <eventTimeZoneOffset>+00:00</eventTimeZoneOffset>
        <epcList>
          <epc>{}</epc>
        </epcList>
        <action>OBSERVE</action>
        <bizStep>urn:epcglobal:cbv:bizstep:receiving</bizStep>
        <disposition>urn:epcglobal:cbv:disp:in_transit</disposition>
      </ObjectEvent>
    </EventList>
  </EPCISBody>
</epcis:EPCISDocument>"#,
            Utc::now().to_rfc3339(),
            context.request_id.as_deref().unwrap_or(&Uuid::new_v4().to_string())
        );

        Ok(xml.into_bytes())
    }

    /// Get file path for a given route and filename
    pub fn get_file_path(&self, route_id: &str, filename: &str) -> PathBuf {
        self.base_dir.join(route_id).join(filename)
    }

    /// Get statistics
    pub async fn get_stats(&self) -> (u64, u64) {
        let stats = self.stats.read().await;
        (stats.files_generated, stats.total_bytes)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[tokio::test]
    async fn test_generate_pdf() {
        let temp_dir = TempDir::new().unwrap();
        let generator = FileGenerator::new(temp_dir.path());

        let context = GenerationContext {
            route_id: "labels".to_string(),
            file_type: FileType::Pdf,
            request_id: Some("test-123".to_string()),
            metadata: serde_json::json!({}),
        };

        let path = generator.generate_file(context).await.unwrap();
        assert!(path.exists());
        assert!(path.extension().unwrap() == "pdf");
    }

    #[tokio::test]
    async fn test_generate_csv() {
        let temp_dir = TempDir::new().unwrap();
        let generator = FileGenerator::new(temp_dir.path());

        let context = GenerationContext {
            route_id: "invoices".to_string(),
            file_type: FileType::Csv,
            request_id: Some("invoice-456".to_string()),
            metadata: serde_json::json!([]),
        };

        let path = generator.generate_file(context).await.unwrap();
        assert!(path.exists());
        assert!(path.extension().unwrap() == "csv");
    }

    #[tokio::test]
    async fn test_generate_json() {
        let temp_dir = TempDir::new().unwrap();
        let generator = FileGenerator::new(temp_dir.path());

        let context = GenerationContext {
            route_id: "provenance".to_string(),
            file_type: FileType::Json,
            request_id: Some("provenance-789".to_string()),
            metadata: serde_json::json!({"test": "data"}),
        };

        let path = generator.generate_file(context).await.unwrap();
        assert!(path.exists());
        assert!(path.extension().unwrap() == "json");

        let content = tokio::fs::read_to_string(&path).await.unwrap();
        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
        assert!(json.get("route_id").is_some());
    }
}