Skip to main content

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