From e87f476b430eeaeef2a0eef24b4e0a49f6d05237 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Sat, 24 Jan 2026 23:09:57 +0100 Subject: [PATCH] Wire up the API with the new methods --- api.go | 606 ++++++++++++++++++++++++---------------------------- api_test.go | 11 +- 2 files changed, 285 insertions(+), 332 deletions(-) diff --git a/api.go b/api.go index 3cb8cf0..984685d 100644 --- a/api.go +++ b/api.go @@ -21,21 +21,16 @@ type APIStatisticsRequest struct { KillmailLimit *int `json:"killmailLimit,omitempty"` } -type APIItemCount struct { - ItemID int64 `json:"itemId"` - Count int64 `json:"count"` +type APIAnalyticsRequest struct { + Filters AnalyticsFilters `json:"filters"` + Limit *int `json:"limit,omitempty"` } -type APIFitStatistics struct { - TotalKillmails int64 `json:"totalKillmails"` - Ships []APIItemCount `json:"ships"` - SystemBreakdown []APIItemCount `json:"systemBreakdown"` - HighSlotModules []APIItemCount `json:"highSlotModules"` - MidSlotModules []APIItemCount `json:"midSlotModules"` - LowSlotModules []APIItemCount `json:"lowSlotModules"` - Rigs []APIItemCount `json:"rigs"` - Drones []APIItemCount `json:"drones"` - KillmailIDs []int64 `json:"killmailIds,omitempty"` +type APIModuleCoOccurrenceRequest struct { + Filters AnalyticsFilters `json:"filters"` + SelectedModule string `json:"selectedModule"` + SelectedSlot string `json:"selectedSlot"` + Limit *int `json:"limit,omitempty"` } type APISearchResult struct { @@ -69,45 +64,6 @@ type APIImageData struct { NotFound bool `json:"notFound,omitempty"` } -const cacheTTL = 24 * time.Hour - -func convertFitStatistics(stats *FitStatistics) *APIFitStatistics { - api := &APIFitStatistics{ - TotalKillmails: stats.TotalKillmails, - Ships: convertMapToItemCounts(stats.ShipBreakdown), - SystemBreakdown: convertMapToItemCounts(stats.SystemBreakdown), - HighSlotModules: convertModuleMapToItemCounts(stats.HighSlotModules), - MidSlotModules: convertModuleMapToItemCounts(stats.MidSlotModules), - LowSlotModules: convertModuleMapToItemCounts(stats.LowSlotModules), - Rigs: convertModuleMapToItemCounts(stats.Rigs), - Drones: convertModuleMapToItemCounts(stats.Drones), - KillmailIDs: stats.KillmailIDs, - } - return api -} - -func convertMapToItemCounts(m map[int64]int64) []APIItemCount { - result := make([]APIItemCount, 0, len(m)) - for id, count := range m { - result = append(result, APIItemCount{ - ItemID: id, - Count: count, - }) - } - return result -} - -func convertModuleMapToItemCounts(m map[int32]int64) []APIItemCount { - result := make([]APIItemCount, 0, len(m)) - for id, count := range m { - result = append(result, APIItemCount{ - ItemID: int64(id), - Count: count, - }) - } - return result -} - func handleStatistics(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -127,7 +83,6 @@ func handleStatistics(w http.ResponseWriter, r *http.Request) { params.Systems = req.Systems params.Modules = req.Modules params.Groups = req.Groups - // Killmail limit defaults to 20 when not provided or invalid if req.KillmailLimit != nil && *req.KillmailLimit > 0 { params.KillmailLimit = *req.KillmailLimit } else { @@ -146,10 +101,238 @@ func handleStatistics(w http.ResponseWriter, r *http.Request) { return } - apiStats := convertFitStatistics(stats) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) +} + +// Analytics handlers +func handleAnalyticsTimeByHour(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + return db.QueryTimeByHour(r.Context(), req.Filters) + }) +} + +func handleAnalyticsTimeByDay(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + return db.QueryTimeByDay(r.Context(), req.Filters) + }) +} + +func handleAnalyticsTimeByDate(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + return db.QueryTimeByDate(r.Context(), req.Filters) + }) +} + +func handleAnalyticsTimeByMonth(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + return db.QueryTimeByMonth(r.Context(), req.Filters) + }) +} + +func handleAnalyticsLocationBySystem(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryLocationBySystem(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsLocationByRegion(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + return db.QueryLocationByRegion(r.Context(), req.Filters) + }) +} + +func handleAnalyticsLocationByConstellation(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + return db.QueryLocationByConstellation(r.Context(), req.Filters) + }) +} + +func handleAnalyticsLocationBySecurity(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + return db.QueryLocationBySecurity(r.Context(), req.Filters) + }) +} + +func handleAnalyticsShipByVictim(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryShipByVictim(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsShipByAttacker(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryShipByAttacker(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsPlayerByVictimCharacter(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryPlayerByVictimCharacter(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsPlayerByVictimCorporation(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryPlayerByVictimCorporation(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsPlayerByVictimAlliance(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryPlayerByVictimAlliance(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsPlayerByAttackerCharacter(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryPlayerByAttackerCharacter(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsPlayerByAttackerCorporation(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryPlayerByAttackerCorporation(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsPlayerByAttackerAlliance(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryPlayerByAttackerAlliance(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsModuleBySlotType(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + return db.QueryModuleBySlotType(r.Context(), req.Filters) + }) +} + +func handleAnalyticsModuleByModule(w http.ResponseWriter, r *http.Request) { + handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { + limit := 100 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + return db.QueryModuleByModule(r.Context(), req.Filters, limit) + }) +} + +func handleAnalyticsModuleCoOccurrence(w http.ResponseWriter, r *http.Request) { + flog := logger.Default.WithPrefix("handleAnalyticsModuleCoOccurrence") + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req APIModuleCoOccurrenceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + flog.Error("Failed to decode request: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + db, err := GetDB() + if err != nil { + flog.Error("Failed to get DB: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + limit := 50 + if req.Limit != nil && *req.Limit > 0 { + limit = *req.Limit + } + + results, err := db.QueryModuleCoOccurrence(r.Context(), req.Filters, req.SelectedModule, req.SelectedSlot, limit) + if err != nil { + flog.Error("Query failed: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(apiStats) + if err := json.NewEncoder(w).Encode(results); err != nil { + flog.Error("Failed to encode response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} + +type analyticsQueryFunc func(db DB, req APIAnalyticsRequest) (interface{}, error) + +func handleAnalyticsQuery(w http.ResponseWriter, r *http.Request, queryFn analyticsQueryFunc) { + flog := logger.Default.WithPrefix("handleAnalyticsQuery") + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req APIAnalyticsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + flog.Error("Failed to decode request: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + db, err := GetDB() + if err != nil { + flog.Error("Failed to get DB: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + results, err := queryFn(db, req) + if err != nil { + flog.Error("Query failed: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(results); err != nil { + flog.Error("Failed to encode response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } } func handleSearch(w http.ResponseWriter, r *http.Request) { @@ -302,7 +485,6 @@ func handleItemNames(w http.ResponseWriter, r *http.Request) { flog.Trace("Database connection obtained") flog.Debug("Getting item types for %d IDs", len(ids)) - // Convert []int32 to []int64 for GetItemTypes itemIDs := make([]int64, len(ids)) for i, id := range ids { itemIDs[i] = int64(id) @@ -315,7 +497,6 @@ func handleItemNames(w http.ResponseWriter, r *http.Request) { return } - // Convert to APIItemNames format (map[string]string) names := make(APIItemNames) for _, item := range items { names[strconv.FormatInt(int64(item.TypeID), 10)] = item.TypeName @@ -416,260 +597,6 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { } } -// func handleImage(w http.ResponseWriter, r *http.Request) { -// flog := logger.Default.WithPrefix("handleImage") -// flog.Trace("Request received: %s %s", r.Method, r.URL.Path) - -// if r.Method != http.MethodGet { -// flog.Debug("Method not allowed: %s", r.Method) -// http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) -// return -// } - -// path := strings.TrimPrefix(r.URL.Path, "/api/images/") -// flog.Trace("Trimmed path: %q", path) -// parts := strings.Split(strings.Trim(path, "/"), "/") -// flog.Trace("Path parts: %v", parts) -// if len(parts) < 1 || parts[0] == "" { -// flog.Error("Invalid image path: %s (original: %s)", path, r.URL.Path) -// http.Error(w, "Invalid image path", http.StatusBadRequest) -// return -// } - -// typeIDStr := parts[0] -// sizeStr := "32" -// if len(parts) > 1 && parts[1] != "" { -// sizeStr = strings.TrimSuffix(parts[1], ".png") -// } -// flog.Debug("Parsed: typeID=%q, size=%q", typeIDStr, sizeStr) - -// typeID, err := strconv.ParseInt(typeIDStr, 10, 64) -// if err != nil { -// flog.Error("Invalid typeID %q: %v", typeIDStr, err) -// http.Error(w, "Invalid type ID", http.StatusBadRequest) -// return -// } - -// size, err := strconv.Atoi(sizeStr) -// if err != nil { -// flog.Error("Invalid size %q: %v", sizeStr, err) -// http.Error(w, "Invalid size", http.StatusBadRequest) -// return -// } - -// cacheKey := fmt.Sprintf("image:%d_%d", typeID, size) -// flog.Debug("Image request: typeID=%d, size=%d, cacheKey=%s", typeID, size, cacheKey) - -// // Check database cache -// cachedData, found := db.GetCacheEntry(cacheKey, cacheTTL) -// if found { -// flog.Trace("Serving from cache") -// // If found but data is nil, it means 404 was cached -// if cachedData == nil { -// flog.Trace("Cached 404, returning 404") -// http.Error(w, "Image not found", http.StatusNotFound) -// return -// } -// // Determine content type from data -// contentType := "image/png" -// if len(cachedData) > 0 { -// // Try to detect from magic bytes -// if len(cachedData) >= 2 && cachedData[0] == 0xFF && cachedData[1] == 0xD8 { -// contentType = "image/jpeg" -// } else if len(cachedData) >= 8 && string(cachedData[0:8]) == "\x89PNG\r\n\x1a\n" { -// contentType = "image/png" -// } -// } -// w.Header().Set("Content-Type", contentType) -// w.Header().Set("Cache-Control", "public, max-age=86400") -// w.Write(cachedData) -// return -// } - -// flog.Debug("Fetching from ESI") -// esiURL := fmt.Sprintf("https://images.evetech.net/types/%d/icon?size=%d", typeID, size) -// flog.Trace("ESI URL: %s", esiURL) -// resp, err := http.Get(esiURL) -// if err != nil { -// flog.Error("Failed to fetch from ESI: %v", err) -// http.Error(w, "Failed to fetch image", http.StatusInternalServerError) -// return -// } -// defer resp.Body.Close() - -// flog.Debug("ESI response status: %d for typeID=%d, size=%d", resp.StatusCode, typeID, size) -// if resp.StatusCode == http.StatusNotFound { -// flog.Debug("Image not found on ESI: typeID=%d, size=%d", typeID, size) -// // Cache 404 as nil blob in database -// if err := db.CacheEntry(cacheKey, nil); err != nil { -// flog.Error("Failed to cache 404: %v", err) -// } -// http.Error(w, "Image not found", http.StatusNotFound) -// return -// } - -// if resp.StatusCode != http.StatusOK { -// flog.Error("ESI returned status %d for typeID=%d, size=%d, URL=%s", resp.StatusCode, typeID, size, esiURL) -// http.Error(w, "Failed to fetch image", http.StatusInternalServerError) -// return -// } - -// data, err := io.ReadAll(resp.Body) -// if err != nil { -// flog.Error("Failed to read image data: %v", err) -// http.Error(w, "Failed to read image", http.StatusInternalServerError) -// return -// } - -// contentType := resp.Header.Get("Content-Type") -// if contentType == "" { -// contentType = "image/png" -// } - -// // Store as blob in database cache -// if err := db.CacheEntry(cacheKey, data); err != nil { -// flog.Error("Failed to cache image: %v", err) -// } - -// flog.Info("Cached image: typeID=%d, size=%d, size=%d bytes", typeID, size, len(data)) - -// w.Header().Set("Content-Type", contentType) -// w.Header().Set("Cache-Control", "public, max-age=86400") -// w.Write(data) -// } - -// func handleImageBatch(w http.ResponseWriter, r *http.Request) { -// flog := logger.Default.WithPrefix("handleImageBatch") -// flog.Trace("Request received: %s %s", r.Method, r.URL.Path) - -// if r.Method != http.MethodPost { -// flog.Debug("Method not allowed: %s", r.Method) -// http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) -// return -// } - -// var req APIImageBatchRequest -// if err := json.NewDecoder(r.Body).Decode(&req); err != nil { -// flog.Error("Failed to decode request body: %v", err) -// http.Error(w, "Invalid request body", http.StatusBadRequest) -// return -// } - -// if len(req.Images) == 0 { -// w.Header().Set("Content-Type", "application/json") -// json.NewEncoder(w).Encode(APIImageBatchResponse{Images: make(map[string]APIImageData)}) -// return -// } - -// type imageResult struct { -// key string -// data APIImageData -// } - -// results := make(map[string]APIImageData) -// resultChan := make(chan imageResult, len(req.Images)) -// var wg sync.WaitGroup - -// for _, imgReq := range req.Images { -// wg.Add(1) -// go func(img APIImageRequest) { -// defer wg.Done() -// cacheKey := fmt.Sprintf("image:%d_%d", img.TypeID, img.Size) - -// // Check database cache -// cachedData, found := db.GetCacheEntry(cacheKey, cacheTTL) -// if found { -// if cachedData == nil { -// resultChan <- imageResult{ -// key: fmt.Sprintf("%d_%d", img.TypeID, img.Size), // Return without prefix for response -// data: APIImageData{NotFound: true}, -// } -// return -// } -// // Determine content type from data -// contentType := "image/png" -// if len(cachedData) > 0 { -// if len(cachedData) >= 2 && cachedData[0] == 0xFF && cachedData[1] == 0xD8 { -// contentType = "image/jpeg" -// } else if len(cachedData) >= 8 && string(cachedData[0:8]) == "\x89PNG\r\n\x1a\n" { -// contentType = "image/png" -// } -// } -// resultChan <- imageResult{ -// key: fmt.Sprintf("%d_%d", img.TypeID, img.Size), // Return without prefix for response -// data: APIImageData{ -// Data: base64.StdEncoding.EncodeToString(cachedData), -// ContentType: contentType, -// }, -// } -// return -// } - -// esiURL := fmt.Sprintf("https://images.evetech.net/types/%d/icon?size=%d", img.TypeID, img.Size) -// resp, err := http.Get(esiURL) -// if err != nil { -// flog.Error("Failed to fetch from ESI: %v", err) -// return -// } -// defer resp.Body.Close() - -// if resp.StatusCode == http.StatusNotFound { -// // Cache 404 as nil blob in database -// if err := db.CacheEntry(cacheKey, nil); err != nil { -// flog.Error("Failed to cache 404: %v", err) -// } -// resultChan <- imageResult{ -// key: fmt.Sprintf("%d_%d", img.TypeID, img.Size), // Return without prefix for response -// data: APIImageData{NotFound: true}, -// } -// return -// } - -// if resp.StatusCode != http.StatusOK { -// flog.Error("ESI returned status %d for typeID=%d, size=%d", resp.StatusCode, img.TypeID, img.Size) -// return -// } - -// data, err := io.ReadAll(resp.Body) -// if err != nil { -// flog.Error("Failed to read image data: %v", err) -// return -// } - -// contentType := resp.Header.Get("Content-Type") -// if contentType == "" { -// contentType = "image/png" -// } - -// // Store as blob in database cache -// if err := db.CacheEntry(cacheKey, data); err != nil { -// flog.Error("Failed to cache image: %v", err) -// } - -// encodedData := base64.StdEncoding.EncodeToString(data) -// resultChan <- imageResult{ -// key: fmt.Sprintf("%d_%d", img.TypeID, img.Size), // Return without prefix for response -// data: APIImageData{ -// Data: encodedData, -// ContentType: contentType, -// }, -// } -// }(imgReq) -// } - -// go func() { -// wg.Wait() -// close(resultChan) -// }() - -// for result := range resultChan { -// results[result.key] = result.data -// } - -// w.Header().Set("Content-Type", "application/json") -// json.NewEncoder(w).Encode(APIImageBatchResponse{Images: results}) -// } - func handleImageBatch(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleImageBatch") @@ -685,19 +612,6 @@ func handleImageBatch(w http.ResponseWriter, r *http.Request) { return } - // CACHING DISABLED TEMPORARILY FOR TESTING - // // Try to get from cache first - // cachedData, err := db.CacheGet(cacheKey) - // if err == nil { - // flog.Info("Returning cached images") - // w.Header().Set("Content-Type", "application/json") - // w.Write(cachedData) - // return - // } - // if err != ErrCacheMiss { - // flog.Debug("Cache get error: %v", err) - // } - response := APIImageBatchResponse{ Images: make(map[string]APIImageData), } @@ -707,7 +621,6 @@ func handleImageBatch(w http.ResponseWriter, r *http.Request) { for _, img := range req.Images { key := fmt.Sprintf("%d_%d", img.TypeID, img.Size) - // Fetch image from EVE Online's image service esiURL := fmt.Sprintf("https://images.evetech.net/types/%d/icon?size=%d", img.TypeID, img.Size) flog.Debug("Fetching image: %s", esiURL) resp, err := client.Get(esiURL) @@ -729,7 +642,6 @@ func handleImageBatch(w http.ResponseWriter, r *http.Request) { continue } - // Read image data data, err := io.ReadAll(resp.Body) if err != nil { flog.Debug("Failed to read image data for typeID %d size %d: %v", img.TypeID, img.Size, err) @@ -748,8 +660,6 @@ func handleImageBatch(w http.ResponseWriter, r *http.Request) { } } - // CACHING DISABLED TEMPORARILY FOR TESTING - w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { flog.Error("Failed to encode response: %v", err) @@ -774,12 +684,52 @@ func StartAPIServer(port string) { })) mux.HandleFunc("/api/images/batch", corsMiddleware(handleImageBatch)) + // Analytics endpoints + mux.HandleFunc("/api/analytics/time/hour", corsMiddleware(handleAnalyticsTimeByHour)) + mux.HandleFunc("/api/analytics/time/day", corsMiddleware(handleAnalyticsTimeByDay)) + mux.HandleFunc("/api/analytics/time/date", corsMiddleware(handleAnalyticsTimeByDate)) + mux.HandleFunc("/api/analytics/time/month", corsMiddleware(handleAnalyticsTimeByMonth)) + mux.HandleFunc("/api/analytics/location/system", corsMiddleware(handleAnalyticsLocationBySystem)) + mux.HandleFunc("/api/analytics/location/region", corsMiddleware(handleAnalyticsLocationByRegion)) + mux.HandleFunc("/api/analytics/location/constellation", corsMiddleware(handleAnalyticsLocationByConstellation)) + mux.HandleFunc("/api/analytics/location/security", corsMiddleware(handleAnalyticsLocationBySecurity)) + mux.HandleFunc("/api/analytics/ship/victim", corsMiddleware(handleAnalyticsShipByVictim)) + mux.HandleFunc("/api/analytics/ship/attacker", corsMiddleware(handleAnalyticsShipByAttacker)) + mux.HandleFunc("/api/analytics/player/victim/character", corsMiddleware(handleAnalyticsPlayerByVictimCharacter)) + mux.HandleFunc("/api/analytics/player/victim/corporation", corsMiddleware(handleAnalyticsPlayerByVictimCorporation)) + mux.HandleFunc("/api/analytics/player/victim/alliance", corsMiddleware(handleAnalyticsPlayerByVictimAlliance)) + mux.HandleFunc("/api/analytics/player/attacker/character", corsMiddleware(handleAnalyticsPlayerByAttackerCharacter)) + mux.HandleFunc("/api/analytics/player/attacker/corporation", corsMiddleware(handleAnalyticsPlayerByAttackerCorporation)) + mux.HandleFunc("/api/analytics/player/attacker/alliance", corsMiddleware(handleAnalyticsPlayerByAttackerAlliance)) + mux.HandleFunc("/api/analytics/module/slot-type", corsMiddleware(handleAnalyticsModuleBySlotType)) + mux.HandleFunc("/api/analytics/module/module", corsMiddleware(handleAnalyticsModuleByModule)) + mux.HandleFunc("/api/analytics/module/co-occurrence", corsMiddleware(handleAnalyticsModuleCoOccurrence)) + flog.Debug("Registered routes:") flog.Debug(" POST /api/statistics") flog.Debug(" GET /api/search") flog.Debug(" GET /api/items/ (names)") flog.Debug(" GET /api/items/{id}/group") flog.Debug(" POST /api/images/batch") + flog.Debug(" POST /api/analytics/time/hour") + flog.Debug(" POST /api/analytics/time/day") + flog.Debug(" POST /api/analytics/time/date") + flog.Debug(" POST /api/analytics/time/month") + flog.Debug(" POST /api/analytics/location/system") + flog.Debug(" POST /api/analytics/location/region") + flog.Debug(" POST /api/analytics/location/constellation") + flog.Debug(" POST /api/analytics/location/security") + flog.Debug(" POST /api/analytics/ship/victim") + flog.Debug(" POST /api/analytics/ship/attacker") + flog.Debug(" POST /api/analytics/player/victim/character") + flog.Debug(" POST /api/analytics/player/victim/corporation") + flog.Debug(" POST /api/analytics/player/victim/alliance") + flog.Debug(" POST /api/analytics/player/attacker/character") + flog.Debug(" POST /api/analytics/player/attacker/corporation") + flog.Debug(" POST /api/analytics/player/attacker/alliance") + flog.Debug(" POST /api/analytics/module/slot-type") + flog.Debug(" POST /api/analytics/module/module") + flog.Debug(" POST /api/analytics/module/co-occurrence") flog.Info("Starting API server on port %s", port) flog.Trace("Listening on :%s", port) diff --git a/api_test.go b/api_test.go index 851ef5a..2f0da78 100644 --- a/api_test.go +++ b/api_test.go @@ -42,15 +42,18 @@ func TestAPIStatistics(t *testing.T) { t.Errorf("Expected status 200, got %d", resp.StatusCode) } - var result APIFitStatistics + var result FitStatistics if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { t.Fatalf("Failed to decode response: %v", err) } t.Logf("Total killmails: %d", result.TotalKillmails) - t.Logf("Ships: %d", len(result.Ships)) - if len(result.Ships) > 0 { - t.Logf("First ship: id=%d, count=%d", result.Ships[0].ItemID, result.Ships[0].Count) + t.Logf("Ships: %d", len(result.ShipBreakdown)) + if len(result.ShipBreakdown) > 0 { + for id, count := range result.ShipBreakdown { + t.Logf("First ship: id=%d, count=%d", id, count) + break + } } t.Logf("Systems: %d", len(result.SystemBreakdown)) t.Logf("High slots: %d", len(result.HighSlotModules))