mockforge_http/
file_generator.rs

1//! File generation service for MockForge
2//!
3//! This module provides functionality to generate mock files (PDF, CSV, JSON)
4//! based on route and request context for download URLs in API responses.
5
6use chrono::Utc;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use tokio::sync::RwLock;
10use uuid::Uuid;
11
12/// Types of files that can be generated
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum FileType {
15    /// PDF file
16    Pdf,
17    /// CSV file
18    Csv,
19    /// JSON file
20    Json,
21    /// EPCIS XML file
22    Epcis,
23}
24
25impl FileType {
26    /// Get file extension for this type
27    pub fn extension(&self) -> &'static str {
28        match self {
29            FileType::Pdf => "pdf",
30            FileType::Csv => "csv",
31            FileType::Json => "json",
32            FileType::Epcis => "xml",
33        }
34    }
35
36    /// Get MIME type for this file type
37    pub fn mime_type(&self) -> &'static str {
38        match self {
39            FileType::Pdf => "application/pdf",
40            FileType::Csv => "text/csv",
41            FileType::Json => "application/json",
42            FileType::Epcis => "application/xml",
43        }
44    }
45
46    /// Parse file type from string
47    pub fn from_str(s: &str) -> Option<Self> {
48        match s.to_lowercase().as_str() {
49            "pdf" => Some(FileType::Pdf),
50            "csv" => Some(FileType::Csv),
51            "json" => Some(FileType::Json),
52            "epcis" | "xml" => Some(FileType::Epcis),
53            _ => None,
54        }
55    }
56}
57
58/// File generation context
59#[derive(Debug, Clone)]
60pub struct GenerationContext {
61    /// Route identifier (e.g., "labels", "invoices", "provenance")
62    pub route_id: String,
63    /// File type to generate
64    pub file_type: FileType,
65    /// Request ID or other identifier
66    pub request_id: Option<String>,
67    /// Additional metadata
68    pub metadata: serde_json::Value,
69}
70
71/// File generation service
72#[derive(Debug, Clone)]
73pub struct FileGenerator {
74    /// Base directory for generated files
75    base_dir: PathBuf,
76    /// File generation statistics
77    stats: Arc<RwLock<GenerationStats>>,
78}
79
80/// Statistics for file generation
81#[derive(Debug, Default)]
82struct GenerationStats {
83    files_generated: u64,
84    total_bytes: u64,
85}
86
87impl FileGenerator {
88    /// Create a new file generator
89    pub fn new(base_dir: impl AsRef<Path>) -> Self {
90        let base_dir = base_dir.as_ref().to_path_buf();
91        Self {
92            base_dir,
93            stats: Arc::new(RwLock::new(GenerationStats::default())),
94        }
95    }
96
97    /// Generate a file based on context
98    pub async fn generate_file(&self, context: GenerationContext) -> anyhow::Result<PathBuf> {
99        // Create directory structure: mock-files/{route_id}/
100        let route_dir = self.base_dir.join(&context.route_id);
101        tokio::fs::create_dir_all(&route_dir).await?;
102
103        // Generate unique filename
104        let filename = format!(
105            "{}_{}.{}",
106            context.request_id.as_ref().unwrap_or(&Uuid::new_v4().to_string()),
107            Utc::now().timestamp(),
108            context.file_type.extension()
109        );
110        let file_path = route_dir.join(&filename);
111
112        // Generate file content based on type
113        let content = match context.file_type {
114            FileType::Pdf => self.generate_pdf(&context)?,
115            FileType::Csv => self.generate_csv(&context)?,
116            FileType::Json => self.generate_json(&context)?,
117            FileType::Epcis => self.generate_epcis(&context)?,
118        };
119
120        // Write file
121        tokio::fs::write(&file_path, content).await?;
122
123        // Update statistics
124        {
125            let mut stats = self.stats.write().await;
126            stats.files_generated += 1;
127            stats.total_bytes += file_path.metadata()?.len();
128        }
129
130        tracing::debug!("Generated file: {:?}", file_path);
131        Ok(file_path)
132    }
133
134    /// Generate PDF content (simple text-based PDF)
135    fn generate_pdf(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
136        // For mock purposes, generate a simple PDF with minimal structure
137        // This is a minimal PDF that displays text
138        let title = format!("Mock {} Document", context.route_id);
139        let content = format!(
140            "MockForge Generated Document\n\
141            Route: {}\n\
142            Generated: {}\n\
143            Request ID: {}\n\
144            \n\
145            This is a mock document generated by MockForge.\n\
146            In a real implementation, this would contain actual data.\n",
147            context.route_id,
148            Utc::now().to_rfc3339(),
149            context.request_id.as_deref().unwrap_or("N/A")
150        );
151
152        // Generate minimal PDF structure
153        // PDF format: %PDF-1.4\n...\n%%EOF
154        let pdf_content = format!(
155            "%PDF-1.4\n\
156            1 0 obj\n\
157            << /Type /Catalog /Pages 2 0 R >>\n\
158            endobj\n\
159            2 0 obj\n\
160            << /Type /Pages /Kids [3 0 R] /Count 1 >>\n\
161            endobj\n\
162            3 0 obj\n\
163            << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>\n\
164            endobj\n\
165            4 0 obj\n\
166            << /Length {} >>\n\
167            stream\n\
168            BT\n\
169            /F1 12 Tf\n\
170            100 700 Td\n\
171            ({}) Tj\n\
172            ET\n\
173            endstream\n\
174            endobj\n\
175            xref\n\
176            0 5\n\
177            0000000000 65535 f\n\
178            0000000009 00000 n\n\
179            0000000058 00000 n\n\
180            0000000115 00000 n\n\
181            0000000215 00000 n\n\
182            trailer\n\
183            << /Size 5 /Root 1 0 R >>\n\
184            startxref\n\
185            {}\n\
186            %%EOF",
187            content.len(),
188            content.replace("(", "\\(").replace(")", "\\)"),
189            0 // Placeholder for xref offset
190        );
191
192        Ok(pdf_content.into_bytes())
193    }
194
195    /// Generate CSV content
196    fn generate_csv(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
197        let mut csv = String::new();
198
199        // Add header
200        csv.push_str("ID,Route,Generated At,Request ID\n");
201
202        // Add data row
203        csv.push_str(&format!(
204            "{},{},{},{}\n",
205            Uuid::new_v4(),
206            context.route_id,
207            Utc::now().to_rfc3339(),
208            context.request_id.as_deref().unwrap_or("N/A")
209        ));
210
211        // If metadata contains array data, add it
212        if let Some(metadata_array) = context.metadata.as_array() {
213            for item in metadata_array {
214                if let Some(obj) = item.as_object() {
215                    let row: Vec<String> =
216                        obj.values().map(|v| v.to_string().trim_matches('"').to_string()).collect();
217                    csv.push_str(&row.join(","));
218                    csv.push('\n');
219                }
220            }
221        }
222
223        Ok(csv.into_bytes())
224    }
225
226    /// Generate JSON content
227    fn generate_json(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
228        let json = serde_json::json!({
229            "route_id": context.route_id,
230            "generated_at": Utc::now().to_rfc3339(),
231            "request_id": context.request_id,
232            "metadata": context.metadata,
233            "mockforge_version": env!("CARGO_PKG_VERSION"),
234        });
235
236        Ok(serde_json::to_vec_pretty(&json)?)
237    }
238
239    /// Generate EPCIS XML content
240    fn generate_epcis(&self, context: &GenerationContext) -> anyhow::Result<Vec<u8>> {
241        let xml = format!(
242            r#"<?xml version="1.0" encoding="UTF-8"?>
243<epcis:EPCISDocument xmlns:epcis="urn:epcglobal:epcis:xsd:1" xmlns:cbv="urn:epcglobal:cbv:mda" xmlns:gdst="https://ref.gs1.org/cbv/">
244  <EPCISHeader>
245    <epcis:version>2.0</epcis:version>
246  </EPCISHeader>
247  <EPCISBody>
248    <EventList>
249      <ObjectEvent>
250        <eventTime>{}</eventTime>
251        <eventTimeZoneOffset>+00:00</eventTimeZoneOffset>
252        <epcList>
253          <epc>{}</epc>
254        </epcList>
255        <action>OBSERVE</action>
256        <bizStep>urn:epcglobal:cbv:bizstep:receiving</bizStep>
257        <disposition>urn:epcglobal:cbv:disp:in_transit</disposition>
258      </ObjectEvent>
259    </EventList>
260  </EPCISBody>
261</epcis:EPCISDocument>"#,
262            Utc::now().to_rfc3339(),
263            context.request_id.as_deref().unwrap_or(&Uuid::new_v4().to_string())
264        );
265
266        Ok(xml.into_bytes())
267    }
268
269    /// Get file path for a given route and filename
270    pub fn get_file_path(&self, route_id: &str, filename: &str) -> PathBuf {
271        self.base_dir.join(route_id).join(filename)
272    }
273
274    /// Get statistics
275    pub async fn get_stats(&self) -> (u64, u64) {
276        let stats = self.stats.read().await;
277        (stats.files_generated, stats.total_bytes)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use tempfile::TempDir;
285
286    #[tokio::test]
287    async fn test_generate_pdf() {
288        let temp_dir = TempDir::new().unwrap();
289        let generator = FileGenerator::new(temp_dir.path());
290
291        let context = GenerationContext {
292            route_id: "labels".to_string(),
293            file_type: FileType::Pdf,
294            request_id: Some("test-123".to_string()),
295            metadata: serde_json::json!({}),
296        };
297
298        let path = generator.generate_file(context).await.unwrap();
299        assert!(path.exists());
300        assert!(path.extension().unwrap() == "pdf");
301    }
302
303    #[tokio::test]
304    async fn test_generate_csv() {
305        let temp_dir = TempDir::new().unwrap();
306        let generator = FileGenerator::new(temp_dir.path());
307
308        let context = GenerationContext {
309            route_id: "invoices".to_string(),
310            file_type: FileType::Csv,
311            request_id: Some("invoice-456".to_string()),
312            metadata: serde_json::json!([]),
313        };
314
315        let path = generator.generate_file(context).await.unwrap();
316        assert!(path.exists());
317        assert!(path.extension().unwrap() == "csv");
318    }
319
320    #[tokio::test]
321    async fn test_generate_json() {
322        let temp_dir = TempDir::new().unwrap();
323        let generator = FileGenerator::new(temp_dir.path());
324
325        let context = GenerationContext {
326            route_id: "provenance".to_string(),
327            file_type: FileType::Json,
328            request_id: Some("provenance-789".to_string()),
329            metadata: serde_json::json!({"test": "data"}),
330        };
331
332        let path = generator.generate_file(context).await.unwrap();
333        assert!(path.exists());
334        assert!(path.extension().unwrap() == "json");
335
336        let content = tokio::fs::read_to_string(&path).await.unwrap();
337        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
338        assert!(json.get("route_id").is_some());
339    }
340}