Phase 2: API Endpoints

This commit is contained in:
2026-02-27 12:00:40 +01:00
parent d187e0e985
commit 69ff9cb60d
7 changed files with 869 additions and 11 deletions
+14
View File
@@ -90,6 +90,19 @@ func main() {
logger.Info("PDF-Templates erfolgreich initialisiert") 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() r := gin.Default()
router.SetupGin(r) router.SetupGin(r)
@@ -103,6 +116,7 @@ func main() {
equipment.RegisterRoutes(protected) equipment.RegisterRoutes(protected)
maintenance.RegisterRoutes(protected) maintenance.RegisterRoutes(protected)
importero.RegisterRoutes(protected) importero.RegisterRoutes(protected)
importer.RegisterRoutes(protected) // New pluggable import/export system
pdfrender.RegisterRoutes(protected) pdfrender.RegisterRoutes(protected)
transfero.RegisterRoutes(protected) transfero.RegisterRoutes(protected)
appsystem.RegisterRoutes(protected) appsystem.RegisterRoutes(protected)
+86
View File
@@ -1,5 +1,10 @@
package importer package importer
import (
"bamort/models"
"fmt"
)
// This file contains the character import data structures. // This file contains the character import data structures.
// These were copied from the deprecated importero package to break dependencies. // These were copied from the deprecated importero package to break dependencies.
@@ -193,3 +198,84 @@ type CharacterImport struct {
Spezialisierung []string `json:"spezialisierung"` Spezialisierung []string `json:"spezialisierung"`
Image string `json:"image,omitempty"` 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
}
+46 -8
View File
@@ -19,19 +19,23 @@ type DetectionCache struct {
// Detector handles smart format detection with optimizations // Detector handles smart format detection with optimizations
type Detector struct { type Detector struct {
registry *AdapterRegistry registry *AdapterRegistry
cache map[string]*DetectionCache cache map[string]*DetectionCache
cacheMu sync.RWMutex cacheMu sync.RWMutex
cacheTTL time.Duration cacheTTL time.Duration
stopCacheCleanup chan struct{}
} }
// NewDetector creates a new detector // NewDetector creates a new detector
func NewDetector(registry *AdapterRegistry) *Detector { func NewDetector(registry *AdapterRegistry) *Detector {
return &Detector{ d := &Detector{
registry: registry, registry: registry,
cache: make(map[string]*DetectionCache), cache: make(map[string]*DetectionCache),
cacheTTL: 5 * time.Minute, // Default TTL cacheTTL: 5 * time.Minute, // Default TTL
stopCacheCleanup: make(chan struct{}),
} }
d.startCacheCleanup()
return d
} }
// DetectFormat implements smart format detection with short-circuit optimization // DetectFormat implements smart format detection with short-circuit optimization
@@ -163,3 +167,37 @@ func getFileExtension(filename string) string {
return strings.ToLower(filename[lastDot:]) 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)
}
}
}
+359
View File
@@ -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})
}
+297
View File
@@ -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)
}
+27 -3
View File
@@ -36,9 +36,10 @@ func (m *AdapterMetadata) SupportsCapability(capability string) bool {
// AdapterRegistry manages registered adapter services // AdapterRegistry manages registered adapter services
type AdapterRegistry struct { type AdapterRegistry struct {
adapters map[string]*AdapterMetadata adapters map[string]*AdapterMetadata
mu sync.RWMutex mu sync.RWMutex
client *http.Client client *http.Client
stopHealthChecker chan struct{}
} }
// NewAdapterRegistry creates a new adapter registry // NewAdapterRegistry creates a new adapter registry
@@ -51,6 +52,7 @@ func NewAdapterRegistry() *AdapterRegistry {
return http.ErrUseLastResponse // Disable redirects for security return http.ErrUseLastResponse // Disable redirects for security
}, },
}, },
stopHealthChecker: make(chan struct{}),
} }
} }
@@ -72,6 +74,28 @@ func (r *AdapterRegistry) Register(meta AdapterMetadata) error {
return nil 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 // Get retrieves an adapter by ID
func (r *AdapterRegistry) Get(id string) *AdapterMetadata { func (r *AdapterRegistry) Get(id string) *AdapterMetadata {
r.mu.RLock() r.mu.RLock()
+40
View File
@@ -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)
}