diff --git a/backend/cmd/main.go b/backend/cmd/main.go index b7c5575..304e5f6 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -90,6 +90,19 @@ func main() { logger.Info("PDF-Templates erfolgreich initialisiert") } + // Initialize import/export adapter registry + logger.Debug("Initialisiere Adapter-Registry...") + adapterRegistry := importer.NewAdapterRegistry() + importer.InitializeRegistry(adapterRegistry) + + // Register adapters from config (if any) + // TODO: Load adapters from environment variable IMPORT_ADAPTERS + // For now, registry is empty and adapters can be registered manually + + // Start background health checker (runs every 30s) + adapterRegistry.StartBackgroundHealthChecker() + logger.Info("Adapter-Registry erfolgreich initialisiert und Health-Checker gestartet") + r := gin.Default() router.SetupGin(r) @@ -103,6 +116,7 @@ func main() { equipment.RegisterRoutes(protected) maintenance.RegisterRoutes(protected) importero.RegisterRoutes(protected) + importer.RegisterRoutes(protected) // New pluggable import/export system pdfrender.RegisterRoutes(protected) transfero.RegisterRoutes(protected) appsystem.RegisterRoutes(protected) diff --git a/backend/importer/character.go b/backend/importer/character.go index 0c63c8d..a9a09cc 100644 --- a/backend/importer/character.go +++ b/backend/importer/character.go @@ -1,5 +1,10 @@ package importer +import ( + "bamort/models" + "fmt" +) + // This file contains the character import data structures. // These were copied from the deprecated importero package to break dependencies. @@ -193,3 +198,84 @@ type CharacterImport struct { Spezialisierung []string `json:"spezialisierung"` Image string `json:"image,omitempty"` } + +// ConvertCharToImport converts a models.Char to CharacterImport format +// This is used for the export functionality +func ConvertCharToImport(char *models.Char) (*CharacterImport, error) { + if char == nil { + return nil, fmt.Errorf("character is nil") + } + + // TODO: Implement full conversion from models.Char to CharacterImport + // For now, return a basic structure + charImport := &CharacterImport{ + ID: fmt.Sprintf("%d", char.ID), + Name: char.Name, + Typ: char.Typ, + Grad: char.Grad, + + // Basic attributes - TODO: Convert from char.Eigenschaften slice + Eigenschaften: Eigenschaften{}, + + // Life points + Lp: Lp{ + Max: char.Lp.Max, + Value: char.Lp.Value, + }, + + // Action points + Ap: Ap{ + Max: char.Ap.Max, + Value: char.Ap.Value, + }, + + // Movement + B: B{ + Max: char.B.Max, + Value: char.B.Value, + }, + + // Experience + Erfahrungsschatz: Erfahrungsschatz{ + Value: char.Erfahrungsschatz.EP, + }, + + // Bennies + Bennies: Bennies{ + Gg: char.Bennies.Gg, + Sg: char.Bennies.Sg, + }, + + // Physical attributes + Alter: char.Alter, + Groesse: char.Groesse, + Gewicht: char.Gewicht, + Hand: char.Hand, + Glaube: char.Glaube, + Rasse: char.Rasse, + Anrede: char.Anrede, + + // Appearance + Merkmale: Merkmale{ + Augenfarbe: char.Merkmale.Augenfarbe, + Haarfarbe: char.Merkmale.Haarfarbe, + Sonstige: char.Merkmale.Sonstige, + }, + + Gestalt: Gestalt{ + Breite: char.Merkmale.Breite, + Groesse: char.Merkmale.Groesse, + }, + + // TODO: Load related data from database + // - Fertigkeiten (skills) + // - Zauber (spells) + // - Waffenfertigkeiten (weapon skills) + // - Ausruestung (equipment) + // - Waffen (weapons) + // - Behaeltnisse (containers) + // - Transportmittel (transportation) + } + + return charImport, nil +} diff --git a/backend/importer/detector.go b/backend/importer/detector.go index e223b57..f1f04f5 100644 --- a/backend/importer/detector.go +++ b/backend/importer/detector.go @@ -19,19 +19,23 @@ type DetectionCache struct { // Detector handles smart format detection with optimizations type Detector struct { - registry *AdapterRegistry - cache map[string]*DetectionCache - cacheMu sync.RWMutex - cacheTTL time.Duration + registry *AdapterRegistry + cache map[string]*DetectionCache + cacheMu sync.RWMutex + cacheTTL time.Duration + stopCacheCleanup chan struct{} } // NewDetector creates a new detector func NewDetector(registry *AdapterRegistry) *Detector { - return &Detector{ - registry: registry, - cache: make(map[string]*DetectionCache), - cacheTTL: 5 * time.Minute, // Default TTL + d := &Detector{ + registry: registry, + cache: make(map[string]*DetectionCache), + cacheTTL: 5 * time.Minute, // Default TTL + stopCacheCleanup: make(chan struct{}), } + d.startCacheCleanup() + return d } // DetectFormat implements smart format detection with short-circuit optimization @@ -163,3 +167,37 @@ func getFileExtension(filename string) string { return strings.ToLower(filename[lastDot:]) } + +// startCacheCleanup starts a background goroutine to periodically clean expired cache entries +func (d *Detector) startCacheCleanup() { + ticker := time.NewTicker(10 * time.Minute) // Cleanup every 10 minutes + go func() { + for { + select { + case <-ticker.C: + d.cleanupExpiredEntries() + case <-d.stopCacheCleanup: + ticker.Stop() + return + } + } + }() +} + +// StopCacheCleanup stops the background cache cleanup goroutine +func (d *Detector) StopCacheCleanup() { + close(d.stopCacheCleanup) +} + +// cleanupExpiredEntries removes expired entries from the cache +func (d *Detector) cleanupExpiredEntries() { + d.cacheMu.Lock() + defer d.cacheMu.Unlock() + + now := time.Now() + for signature, entry := range d.cache { + if now.Sub(entry.cachedAt) > d.cacheTTL { + delete(d.cache, signature) + } + } +} diff --git a/backend/importer/handlers.go b/backend/importer/handlers.go new file mode 100644 index 0000000..c9427b3 --- /dev/null +++ b/backend/importer/handlers.go @@ -0,0 +1,359 @@ +package importer + +import ( + "bamort/database" + "bamort/models" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// Global registry instance (initialized on startup) +var globalRegistry *AdapterRegistry + +// InitializeRegistry initializes the global adapter registry +func InitializeRegistry(registry *AdapterRegistry) { + globalRegistry = registry +} + +// DetectHandler handles format detection for uploaded files +// POST /api/import/detect +// Rate limit: 10 requests/minute per user +func DetectHandler(c *gin.Context) { + // Accept multipart file upload + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"}) + return + } + defer file.Close() + + // Read file data + data, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"}) + return + } + + // Validate JSON depth if JSON file + if err := ValidateJSONDepth(data, 100); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid JSON: %v", err)}) + return + } + + // Detect format using global registry + if globalRegistry == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Import service not initialized"}) + return + } + + adapterID, confidence, err := globalRegistry.Detect(data, header.Filename) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Detection failed: %v", err)}) + return + } + + // Get adapter metadata for suggested name + adapter := globalRegistry.Get(adapterID) + response := gin.H{ + "adapter_id": adapterID, + "confidence": confidence, + } + if adapter != nil { + response["suggested_adapter_name"] = adapter.Name + } + + c.JSON(http.StatusOK, response) +} + +// ImportHandler handles character import from external formats +// POST /api/import/import +// Rate limit: 5 requests/minute per user +func ImportHandler(c *gin.Context) { + // Get user ID from context + userID := getUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + // Accept file and optional adapter_id + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"}) + return + } + defer file.Close() + + // Read file data + data, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"}) + return + } + + // Validate JSON depth + if err := ValidateJSONDepth(data, 100); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid JSON: %v", err)}) + return + } + + // Get adapter ID from form or detect + adapterID := c.PostForm("adapter_id") + if adapterID == "" { + // Auto-detect + if globalRegistry == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Import service not initialized"}) + return + } + detectedID, confidence, err := globalRegistry.Detect(data, header.Filename) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Detection failed: %v", err)}) + return + } + if confidence < 0.7 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Could not reliably detect format", + "confidence": confidence, + "hint": "Please specify adapter_id explicitly", + }) + return + } + adapterID = detectedID + } + + // Import via adapter + charImport, err := globalRegistry.Import(adapterID, data) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("Import failed: %v", err)}) + return + } + + // Validate the imported character + validator := NewValidator() + validationResult := validator.ValidateCharacter(charImport) + if !validationResult.Valid { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Character validation failed", + "errors": validationResult.Errors, + }) + return + } + + // Collect warnings but don't block + warnings := validationResult.Warnings + + // Convert BMRTCharacter to CharacterImport for import logic + charData := &charImport.CharacterImport + + // Import character with transaction safety + result, err := ImportCharacter(charData, userID, adapterID, data) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create character: %v", err)}) + return + } + + // Merge validation warnings into result + result.Warnings = append(result.Warnings, warnings...) + + c.JSON(http.StatusOK, result) +} + +// ListAdaptersHandler returns all registered adapters +// GET /api/import/adapters +func ListAdaptersHandler(c *gin.Context) { + if globalRegistry == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Import service not initialized"}) + return + } + + adapters := globalRegistry.GetHealthy() + c.JSON(http.StatusOK, gin.H{ + "adapters": adapters, + "count": len(adapters), + }) +} + +// ImportHistoryHandler returns user's import history with pagination +// GET /api/import/history?page=1&per_page=20 +func ImportHistoryHandler(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + // Parse pagination parameters + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20")) + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + offset := (page - 1) * perPage + + // Query import history + var histories []ImportHistory + var total int64 + + db := database.DB.Model(&ImportHistory{}).Where("user_id = ?", userID) + db.Count(&total) + + err := db.Order("imported_at DESC"). + Limit(perPage). + Offset(offset). + Find(&histories).Error + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch import history"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "histories": histories, + "total": total, + "page": page, + "per_page": perPage, + "pages": (total + int64(perPage) - 1) / int64(perPage), + }) +} + +// ImportDetailsHandler returns detailed information about a specific import +// GET /api/import/history/:id +func ImportDetailsHandler(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + importID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import ID"}) + return + } + + // Fetch import history with ownership check + var history ImportHistory + err = database.DB.Where("id = ? AND user_id = ?", importID, userID). + First(&history).Error + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Import not found"}) + return + } + + // Fetch related master data imports + var masterDataImports []MasterDataImport + database.DB.Where("import_history_id = ?", importID). + Find(&masterDataImports) + + c.JSON(http.StatusOK, gin.H{ + "history": history, + "master_data_imports": masterDataImports, + }) +} + +// ExportHandler exports a character to an external format +// POST /api/import/export/:id?adapter_id=foundry-vtt-v1 +func ExportHandler(c *gin.Context) { + userID := getUserID(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + charID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid character ID"}) + return + } + + // Load character with ownership check + var char models.Char + err = database.DB.Where("id = ? AND user_id = ?", charID, userID). + First(&char).Error + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Character not found or access denied"}) + return + } + + // Determine adapter ID (override or original) + adapterID := c.Query("adapter_id") + if adapterID == "" { + // Try to get original adapter from import history + var history ImportHistory + err = database.DB.Where("character_id = ?", charID). + Order("imported_at DESC"). + First(&history).Error + + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "No adapter specified and character has no import history", + "hint": "Specify adapter_id query parameter", + }) + return + } + adapterID = history.AdapterID + } + + // Check adapter exists and is healthy + if globalRegistry == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Import service not initialized"}) + return + } + + adapter := globalRegistry.Get(adapterID) + if adapter == nil { + // Suggest available adapters + available := globalRegistry.GetHealthy() + availableIDs := make([]string, len(available)) + for i, a := range available { + availableIDs[i] = a.ID + } + + c.JSON(http.StatusConflict, gin.H{ + "error": fmt.Sprintf("Adapter '%s' not available", adapterID), + "available_adapters": availableIDs, + }) + return + } + + if !adapter.Healthy { + c.JSON(http.StatusConflict, gin.H{ + "error": fmt.Sprintf("Adapter '%s' is currently unhealthy", adapterID), + }) + return + } + + // Convert Char to CharacterImport + charImport, err := ConvertCharToImport(&char) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to convert character: %v", err)}) + return + } + + // Export via adapter + exportedData, err := globalRegistry.Export(adapterID, charImport) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("Export failed: %v", err)}) + return + } + + // Return file download + filename := fmt.Sprintf("%s_%s.json", char.Name, adapterID) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + c.Data(http.StatusOK, "application/json", exportedData) +} + +// respondWithError is a helper to send error responses +func respondWithError(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{"error": message}) +} diff --git a/backend/importer/handlers_test.go b/backend/importer/handlers_test.go new file mode 100644 index 0000000..bfe2fae --- /dev/null +++ b/backend/importer/handlers_test.go @@ -0,0 +1,297 @@ +package importer + +import ( + "bamort/database" + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// setupTestEnvironment sets up the test environment +func setupTestEnvironment(t *testing.T) { + original := os.Getenv("ENVIRONMENT") + os.Setenv("ENVIRONMENT", "test") + t.Cleanup(func() { + if original == "" { + os.Unsetenv("ENVIRONMENT") + } else { + os.Setenv("ENVIRONMENT", original) + } + }) +} + +// setupTestHandlerDB initializes the test database for handler tests +func setupTestHandlerDB(t *testing.T) { + setupTestEnvironment(t) + // For now, just ensure migrations run - full DB setup will be added later + if database.DB != nil { + err := MigrateStructure(database.DB) + assert.NoError(t, err) + } + + t.Cleanup(func() { + // Clean up test data + if database.DB != nil { + database.DB.Exec("DELETE FROM import_histories") + database.DB.Exec("DELETE FROM master_data_imports") + } + }) +} + +// createTestFile creates a multipart file for testing +func createTestFile(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile(fieldName, filename) + assert.NoError(t, err) + + _, err = part.Write(content) + assert.NoError(t, err) + + err = writer.Close() + assert.NoError(t, err) + + return body, writer.FormDataContentType() +} + +// mockAdapter creates a mock adapter for testing +func mockAdapter() *AdapterMetadata { + return &AdapterMetadata{ + ID: "test-adapter-v1", + Name: "Test Adapter", + Version: "1.0", + BmrtVersions: []string{"1.0"}, + SupportedExtensions: []string{".json"}, + BaseURL: "http://localhost:8999", + Capabilities: []string{"import", "export", "detect"}, + Healthy: true, + } +} + +// TestDetectHandler tests the format detection endpoint +func TestDetectHandler(t *testing.T) { + setupTestHandlerDB(t) + gin.SetMode(gin.TestMode) + + // Setup mock registry + registry := NewAdapterRegistry() + registry.Register(*mockAdapter()) + InitializeRegistry(registry) + + // Create test file + testData := []byte(`{"name": "Test Character", "type": "test"}`) + body, contentType := createTestFile(t, "file", "test.json", testData) + + // Create request + req := httptest.NewRequest("POST", "/api/import/detect", body) + req.Header.Set("Content-Type", contentType) + w := httptest.NewRecorder() + + // Setup context + c, _ := gin.CreateTestContext(w) + c.Request = req + + // Call handler (with mock detect - would need adapter server for real test) + // For now, we verify the handler validates input correctly + DetectHandler(c) + + // Without a real adapter server, we expect an error + // This test mainly validates the request parsing + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// TestImportHandler_NoFile tests import with missing file +func TestImportHandler_NoFile(t *testing.T) { + setupTestHandlerDB(t) + gin.SetMode(gin.TestMode) + + registry := NewAdapterRegistry() + InitializeRegistry(registry) + + req := httptest.NewRequest("POST", "/api/import/import", nil) + w := httptest.NewRecorder() + + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("userID", uint(1)) + + ImportHandler(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Contains(t, response["error"], "No file uploaded") +} + +// TestImportHandler_NoAuth tests import without authentication +func TestImportHandler_NoAuth(t *testing.T) { + setupTestHandlerDB(t) + gin.SetMode(gin.TestMode) + + registry := NewAdapterRegistry() + InitializeRegistry(registry) + + testData := []byte(`{"name": "Test"}`) + body, contentType := createTestFile(t, "file", "test.json", testData) + + req := httptest.NewRequest("POST", "/api/import/import", body) + req.Header.Set("Content-Type", contentType) + w := httptest.NewRecorder() + + c, _ := gin.CreateTestContext(w) + c.Request = req + // No userID set - simulates unauthenticated request + + ImportHandler(c) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +// TestListAdaptersHandler tests listing registered adapters +func TestListAdaptersHandler(t *testing.T) { + setupTestHandlerDB(t) + gin.SetMode(gin.TestMode) + + registry := NewAdapterRegistry() + registry.Register(*mockAdapter()) + InitializeRegistry(registry) + + req := httptest.NewRequest("GET", "/api/import/adapters", nil) + w := httptest.NewRecorder() + + c, _ := gin.CreateTestContext(w) + c.Request = req + + ListAdaptersHandler(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Contains(t, response, "adapters") + assert.Contains(t, response, "count") +} + +// TestImportHistoryHandler tests import history retrieval +func TestImportHistoryHandler(t *testing.T) { + setupTestHandlerDB(t) + gin.SetMode(gin.TestMode) + + // Skip if database not available + if database.DB == nil { + t.Skip("Database not available") + } + + registry := NewAdapterRegistry() + InitializeRegistry(registry) + + req := httptest.NewRequest("GET", "/api/import/history", nil) + w := httptest.NewRecorder() + + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("userID", uint(1)) + + ImportHistoryHandler(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Contains(t, response, "histories") + assert.Contains(t, response, "total") + assert.Contains(t, response, "page") +} + +// TestImportHistoryHandler_Pagination tests pagination +func TestImportHistoryHandler_Pagination(t *testing.T) { + setupTestHandlerDB(t) + gin.SetMode(gin.TestMode) + + // Skip if database not available + if database.DB == nil { + t.Skip("Database not available") + } + + registry := NewAdapterRegistry() + InitializeRegistry(registry) + + req := httptest.NewRequest("GET", "/api/import/history?page=2&per_page=10", nil) + w := httptest.NewRecorder() + + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("userID", uint(1)) + + ImportHistoryHandler(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, float64(2), response["page"]) + assert.Equal(t, float64(10), response["per_page"]) +} + +// TestExportHandler_NotFound tests export with non-existent character +func TestExportHandler_NotFound(t *testing.T) { + setupTestHandlerDB(t) + gin.SetMode(gin.TestMode) + + // Skip if database not available + if database.DB == nil { + t.Skip("Database not available") + } + + registry := NewAdapterRegistry() + InitializeRegistry(registry) + + req := httptest.NewRequest("POST", "/api/import/export/999999", nil) + w := httptest.NewRecorder() + + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("userID", uint(1)) + c.Params = gin.Params{gin.Param{Key: "id", Value: "999999"}} + + ExportHandler(c) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// TestValidateFileSizeMiddleware tests file size validation +func TestValidateFileSizeMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Create middleware with 100 byte limit + middleware := ValidateFileSizeMiddleware(100) + + // Create large content + largeContent := bytes.Repeat([]byte("a"), 200) + + req := httptest.NewRequest("POST", "/test", bytes.NewReader(largeContent)) + w := httptest.NewRecorder() + + c, _ := gin.CreateTestContext(w) + c.Request = req + + middleware(c) + + // Try to read more than limit - should error + _, err := io.ReadAll(c.Request.Body) + assert.Error(t, err) +} diff --git a/backend/importer/registry.go b/backend/importer/registry.go index 3b8c6c6..58bf86c 100644 --- a/backend/importer/registry.go +++ b/backend/importer/registry.go @@ -36,9 +36,10 @@ func (m *AdapterMetadata) SupportsCapability(capability string) bool { // AdapterRegistry manages registered adapter services type AdapterRegistry struct { - adapters map[string]*AdapterMetadata - mu sync.RWMutex - client *http.Client + adapters map[string]*AdapterMetadata + mu sync.RWMutex + client *http.Client + stopHealthChecker chan struct{} } // NewAdapterRegistry creates a new adapter registry @@ -51,6 +52,7 @@ func NewAdapterRegistry() *AdapterRegistry { return http.ErrUseLastResponse // Disable redirects for security }, }, + stopHealthChecker: make(chan struct{}), } } @@ -72,6 +74,28 @@ func (r *AdapterRegistry) Register(meta AdapterMetadata) error { return nil } +// StartBackgroundHealthChecker starts a background goroutine that periodically checks adapter health +// Runs every 30 seconds as specified in the plan +func (r *AdapterRegistry) StartBackgroundHealthChecker() { + ticker := time.NewTicker(30 * time.Second) + go func() { + for { + select { + case <-ticker.C: + _ = r.HealthCheck() // Ignore errors, they're logged in adapter metadata + case <-r.stopHealthChecker: + ticker.Stop() + return + } + } + }() +} + +// StopBackgroundHealthChecker stops the background health checker +func (r *AdapterRegistry) StopBackgroundHealthChecker() { + close(r.stopHealthChecker) +} + // Get retrieves an adapter by ID func (r *AdapterRegistry) Get(id string) *AdapterMetadata { r.mu.RLock() diff --git a/backend/importer/routes.go b/backend/importer/routes.go new file mode 100644 index 0000000..ecdfd13 --- /dev/null +++ b/backend/importer/routes.go @@ -0,0 +1,40 @@ +package importer + +import ( + "time" + + "github.com/gin-gonic/gin" +) + +// RegisterRoutes registers all import/export API endpoints +// Following the plan: POST /detect, POST /import, GET /adapters, GET /history, etc. +func RegisterRoutes(r *gin.RouterGroup) { + // Rate limiters per endpoint as specified in the plan + detectLimiter := NewRateLimiter(10, time.Minute) // 10/min + importLimiter := NewRateLimiter(5, time.Minute) // 5/min + exportLimiter := NewRateLimiter(20, time.Minute) // 20/min + + // File size limit (10MB as per plan) + maxFileSize := int64(10 << 20) + + importer := r.Group("/import") + importer.Use(ValidateFileSizeMiddleware(maxFileSize)) + + // Format detection endpoint + importer.POST("/detect", detectLimiter.Middleware(), DetectHandler) + + // Import character from external format + importer.POST("/import", importLimiter.Middleware(), ImportHandler) + + // List registered adapters + importer.GET("/adapters", ListAdaptersHandler) + + // Get user's import history + importer.GET("/history", ImportHistoryHandler) + + // Get details for specific import + importer.GET("/history/:id", ImportDetailsHandler) + + // Export character to external format + importer.POST("/export/:id", exportLimiter.Middleware(), ExportHandler) +}