Phase 2: API Endpoints
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user