321 lines
8.6 KiB
Go
321 lines
8.6 KiB
Go
package importer
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// AdapterMetadata contains information about a registered adapter service
|
|
type AdapterMetadata struct {
|
|
ID string `json:"id"` // e.g., "foundry-vtt-v1"
|
|
Name string `json:"name"` // e.g., "Foundry VTT Character"
|
|
Version string `json:"version"` // e.g., "1.0"
|
|
BmrtVersions []string `json:"bmrt_versions"` // Supported BMRT versions
|
|
SupportedExtensions []string `json:"supported_extensions"` // e.g., [".json"]
|
|
Capabilities []string `json:"capabilities"` // e.g., ["import", "export", "detect"]
|
|
BaseURL string `json:"base_url"` // e.g., "http://adapter-foundry:8181"
|
|
Healthy bool `json:"healthy"` // Runtime health status
|
|
LastCheckedAt time.Time `json:"last_checked_at"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
}
|
|
|
|
// SupportsCapability checks if the adapter supports a specific capability
|
|
func (m *AdapterMetadata) SupportsCapability(capability string) bool {
|
|
for _, cap := range m.Capabilities {
|
|
if cap == capability {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AdapterRegistry manages registered adapter services
|
|
type AdapterRegistry struct {
|
|
adapters map[string]*AdapterMetadata
|
|
mu sync.RWMutex
|
|
client *http.Client
|
|
stopHealthChecker chan struct{}
|
|
}
|
|
|
|
// NewAdapterRegistry creates a new adapter registry
|
|
func NewAdapterRegistry() *AdapterRegistry {
|
|
return &AdapterRegistry{
|
|
adapters: make(map[string]*AdapterMetadata),
|
|
client: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse // Disable redirects for security
|
|
},
|
|
},
|
|
stopHealthChecker: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Register registers or updates an adapter
|
|
func (r *AdapterRegistry) Register(meta AdapterMetadata) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// Validate required fields
|
|
if meta.ID == "" {
|
|
return fmt.Errorf("adapter ID is required")
|
|
}
|
|
if meta.BaseURL == "" {
|
|
return fmt.Errorf("adapter base URL is required")
|
|
}
|
|
|
|
// Register/update the adapter
|
|
r.adapters[meta.ID] = &meta
|
|
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()
|
|
defer r.mu.RUnlock()
|
|
return r.adapters[id]
|
|
}
|
|
|
|
// GetAll returns all registered adapters
|
|
func (r *AdapterRegistry) GetAll() []*AdapterMetadata {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
result := make([]*AdapterMetadata, 0, len(r.adapters))
|
|
for _, adapter := range r.adapters {
|
|
result = append(result, adapter)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetHealthy returns only healthy adapters
|
|
func (r *AdapterRegistry) GetHealthy() []*AdapterMetadata {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
result := make([]*AdapterMetadata, 0)
|
|
for _, adapter := range r.adapters {
|
|
if adapter.Healthy {
|
|
result = append(result, adapter)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Import calls an adapter's import endpoint
|
|
func (r *AdapterRegistry) Import(adapterID string, data []byte) (*BMRTCharacter, error) {
|
|
adapter := r.Get(adapterID)
|
|
if adapter == nil {
|
|
return nil, fmt.Errorf("adapter not found: %s", adapterID)
|
|
}
|
|
|
|
if !adapter.Healthy {
|
|
return nil, fmt.Errorf("adapter is unhealthy: %s", adapterID)
|
|
}
|
|
|
|
if !adapter.SupportsCapability("import") {
|
|
return nil, fmt.Errorf("adapter does not support import: %s", adapterID)
|
|
}
|
|
|
|
url := adapter.BaseURL + "/import"
|
|
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("adapter request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("adapter returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var char BMRTCharacter
|
|
if err := json.NewDecoder(resp.Body).Decode(&char); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return &char, nil
|
|
}
|
|
|
|
// Export calls an adapter's export endpoint
|
|
func (r *AdapterRegistry) Export(adapterID string, char *CharacterImport) ([]byte, error) {
|
|
adapter := r.Get(adapterID)
|
|
if adapter == nil {
|
|
return nil, fmt.Errorf("adapter not found: %s", adapterID)
|
|
}
|
|
|
|
if !adapter.Healthy {
|
|
return nil, fmt.Errorf("adapter is unhealthy: %s", adapterID)
|
|
}
|
|
|
|
if !adapter.SupportsCapability("export") {
|
|
return nil, fmt.Errorf("adapter does not support export: %s", adapterID)
|
|
}
|
|
|
|
// Marshal character to JSON
|
|
charData, err := json.Marshal(char)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal character: %w", err)
|
|
}
|
|
|
|
url := adapter.BaseURL + "/export"
|
|
req, err := http.NewRequest("POST", url, bytes.NewReader(charData))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("adapter request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("adapter returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// AdapterDetectResponse represents the response from an adapter's detect endpoint
|
|
type AdapterDetectResponse struct {
|
|
Confidence float64 `json:"confidence"`
|
|
Version string `json:"version,omitempty"`
|
|
}
|
|
|
|
// Detect calls all healthy adapters' detect endpoints and returns the best match
|
|
func (r *AdapterRegistry) Detect(data []byte, filename string) (string, float64, error) {
|
|
healthy := r.GetHealthy()
|
|
if len(healthy) == 0 {
|
|
return "", 0, fmt.Errorf("no healthy adapters available")
|
|
}
|
|
|
|
// Create a short timeout client for detection
|
|
detectClient := &http.Client{
|
|
Timeout: 2 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
bestAdapterID := ""
|
|
bestConfidence := 0.0
|
|
|
|
for _, adapter := range healthy {
|
|
if !adapter.SupportsCapability("detect") {
|
|
continue
|
|
}
|
|
|
|
url := adapter.BaseURL + "/detect"
|
|
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
|
|
resp, err := detectClient.Do(req)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
var detectResp AdapterDetectResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&detectResp); err == nil {
|
|
if detectResp.Confidence > bestConfidence {
|
|
bestConfidence = detectResp.Confidence
|
|
bestAdapterID = adapter.ID
|
|
}
|
|
}
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// Require minimum confidence threshold
|
|
if bestConfidence < 0.7 {
|
|
return "", bestConfidence, fmt.Errorf("no adapter reached confidence threshold (best: %.2f)", bestConfidence)
|
|
}
|
|
|
|
return bestAdapterID, bestConfidence, nil
|
|
}
|
|
|
|
// HealthCheck performs health checks on all adapters
|
|
func (r *AdapterRegistry) HealthCheck() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
for id, adapter := range r.adapters {
|
|
// Try to ping the metadata endpoint
|
|
url := adapter.BaseURL + "/metadata"
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
adapter.Healthy = false
|
|
adapter.LastError = err.Error()
|
|
adapter.LastCheckedAt = time.Now()
|
|
continue
|
|
}
|
|
|
|
resp, err := r.client.Do(req)
|
|
if err != nil {
|
|
adapter.Healthy = false
|
|
adapter.LastError = err.Error()
|
|
adapter.LastCheckedAt = time.Now()
|
|
continue
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
adapter.Healthy = true
|
|
adapter.LastError = ""
|
|
adapter.LastCheckedAt = time.Now()
|
|
} else {
|
|
adapter.Healthy = false
|
|
adapter.LastError = fmt.Sprintf("status code: %d", resp.StatusCode)
|
|
adapter.LastCheckedAt = time.Now()
|
|
}
|
|
|
|
r.adapters[id] = adapter
|
|
}
|
|
|
|
return nil
|
|
}
|