export: implement streaming CSV with Excel compatibility
Implement Phase 1 CSV Export Optimization: - Replace buffering with true HTTP streaming (ToCSV writes to io.Writer) - Add UTF-8 BOM (0xEF 0xBB 0xBF) for correct Cyrillic display in Excel - Use semicolon (;) delimiter for Russian Excel locale - Use comma (,) as decimal separator in numbers (100,50 instead of 100.50) - Add graceful two-phase error handling: * Before streaming: return JSON errors for validation failures * During streaming: log errors only (HTTP 200 already sent) - Add backward-compatible ToCSVBytes() helper - Add GET /api/configs/:uuid/export route for configuration export New tests (13 total): - Service layer (7 tests): * UTF-8 BOM verification * Semicolon delimiter parsing * Total row formatting * Category sorting * Empty data handling * Backward compatibility wrapper * Writer error handling - Handler layer (6 tests): * Successful CSV export with streaming * Invalid request validation * Empty items validation * Config export with proper headers * 404 for missing configs * Empty config validation All tests passing, build verified. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,15 +47,22 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
|
||||
data := h.buildExportData(&req)
|
||||
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", time.Now().Format("2006-01-02"), req.Name)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
|
||||
// Stream CSV (cannot return JSON after this point)
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err) // Log only
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||
@@ -101,6 +108,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
username := middleware.GetUsername(c)
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
// Get config before streaming (can return JSON error)
|
||||
config, err := h.configService.GetByUUID(uuid, username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
@@ -109,13 +117,20 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
|
||||
data := h.exportService.ConfigToExportData(config, h.componentService)
|
||||
|
||||
csvData, err := h.exportService.ToCSV(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
// Validate before streaming (can return JSON error)
|
||||
if len(data.Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"})
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers before streaming
|
||||
filename := fmt.Sprintf("%s %s SPEC.csv", config.CreatedAt.Format("2006-01-02"), config.Name)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
|
||||
// Stream CSV (cannot return JSON after this point)
|
||||
if err := h.exportService.ToCSV(c.Writer, data); err != nil {
|
||||
c.Error(err) // Log only
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user