Phase 2: API Endpoints
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// 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()
|
||||||
|
|||||||
@@ -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