From b74745f04385c4928bb5c4eba7eeb1d4683387ae Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Mon, 5 Jan 2026 22:06:09 +0100 Subject: [PATCH] Add an API layer for the frontend --- api.go | 325 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ db.go | 2 +- main.go | 11 +- 3 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 api.go diff --git a/api.go b/api.go new file mode 100644 index 0000000..6d2d1d9 --- /dev/null +++ b/api.go @@ -0,0 +1,325 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "zkillsusser/models" + + logger "git.site.quack-lab.dev/dave/cylogger" + "gorm.io/gorm" +) + +type APIStatisticsRequest struct { + Ship *int64 `json:"ship,omitempty"` + Systems []int64 `json:"systems,omitempty"` + Modules []int64 `json:"modules,omitempty"` + Groups []int64 `json:"groups,omitempty"` +} + +type APIItemCount struct { + ItemId int64 `json:"itemId"` + Count int64 `json:"count"` +} + +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"` +} + +type APISearchResult struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type APIItemNames map[string]string + +type APIGroupInfo struct { + GroupID int64 `json:"groupId"` +} + +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), + } + return api +} + +func convertMapToItemCounts(m map[int64]SystemStats) []APIItemCount { + result := make([]APIItemCount, 0, len(m)) + for id, stats := range m { + result = append(result, APIItemCount{ + ItemId: id, + Count: stats.Count, + }) + } + return result +} + +func convertModuleMapToItemCounts(m map[int32]ModuleStats) []APIItemCount { + result := make([]APIItemCount, 0, len(m)) + for id, stats := range m { + result = append(result, APIItemCount{ + ItemId: int64(id), + Count: stats.Count, + }) + } + return result +} + +func handleStatistics(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req APIStatisticsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + params := QueryParams{} + if req.Ship != nil { + params.Ship = *req.Ship + } + params.Systems = req.Systems + params.Modules = req.Modules + params.Groups = req.Groups + + db, err := GetDB() + if err != nil { + logger.Error("Failed to get database: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + stats, err := db.QueryFits(params) + if err != nil { + logger.Error("Failed to query fits: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + apiStats := convertFitStatistics(stats) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(apiStats) +} + +func handleSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query().Get("q") + if query == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]APISearchResult{}) + return + } + + db, err := GetDB() + if err != nil { + logger.Error("Failed to get database: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + results := []APISearchResult{} + + searchPattern := "%" + strings.ToLower(query) + "%" + + var ships []models.InvType + db.DB().Table("invTypes"). + Joins("INNER JOIN invGroups ON invTypes.groupID = invGroups.groupID"). + Where("LOWER(invTypes.\"typeName\") LIKE ? AND invGroups.categoryID IN (6)", searchPattern). + Limit(10). + Find(&ships) + for _, ship := range ships { + results = append(results, APISearchResult{ + ID: int64(ship.TypeID), + Name: ship.TypeName, + Type: "ship", + }) + } + + var systems []models.MapSolarSystem + db.DB().Table("mapSolarSystems"). + Where("LOWER(\"solarSystemName\") LIKE ?", searchPattern). + Limit(10). + Find(&systems) + for _, system := range systems { + results = append(results, APISearchResult{ + ID: int64(system.SolarSystemID), + Name: system.SolarSystemName, + Type: "system", + }) + } + + var modules []models.InvType + db.DB().Table("invTypes"). + Joins("INNER JOIN invGroups ON invTypes.groupID = invGroups.groupID"). + Where("LOWER(invTypes.\"typeName\") LIKE ? AND invGroups.categoryID IN (7, 66)", searchPattern). + Limit(10). + Find(&modules) + for _, module := range modules { + results = append(results, APISearchResult{ + ID: int64(module.TypeID), + Name: module.TypeName, + Type: "module", + }) + } + + var groups []models.InvGroup + db.DB().Table("invGroups"). + Where("LOWER(\"groupName\") LIKE ?", searchPattern). + Limit(10). + Find(&groups) + for _, group := range groups { + results = append(results, APISearchResult{ + ID: int64(group.GroupID), + Name: group.GroupName, + Type: "group", + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(results) +} + +func handleItemNames(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + idsParam := r.URL.Query().Get("ids") + if idsParam == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(APIItemNames{}) + return + } + + idStrs := strings.Split(idsParam, ",") + ids := make([]int32, 0, len(idStrs)) + for _, idStr := range idStrs { + id, err := strconv.ParseInt(idStr, 10, 32) + if err != nil { + continue + } + ids = append(ids, int32(id)) + } + + if len(ids) == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(APIItemNames{}) + return + } + + db, err := GetDB() + if err != nil { + logger.Error("Failed to get database: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + var items []models.InvType + db.DB().Table("invTypes"). + Where("typeID IN ?", ids). + Find(&items) + + names := make(APIItemNames) + for _, item := range items { + names[strconv.FormatInt(int64(item.TypeID), 10)] = item.TypeName + } + + var systems []models.MapSolarSystem + systemIDs := ids + db.DB().Table("mapSolarSystems"). + Where("\"solarSystemID\" IN ?", systemIDs). + Find(&systems) + for _, system := range systems { + names[strconv.FormatInt(int64(system.SolarSystemID), 10)] = system.SolarSystemName + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(names) +} + +func handleItemGroup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/api/items/") + path = strings.TrimSuffix(path, "/group") + parts := strings.Split(path, "/") + if len(parts) == 0 { + http.Error(w, "Invalid item ID", http.StatusBadRequest) + return + } + itemID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + http.Error(w, "Invalid item ID", http.StatusBadRequest) + return + } + + db, err := GetDB() + if err != nil { + logger.Error("Failed to get database: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + var item models.InvType + result := db.DB().Table("invTypes"). + Where("typeID = ?", int32(itemID)). + First(&item) + if result.Error == gorm.ErrRecordNotFound { + http.Error(w, "Item not found", http.StatusNotFound) + return + } + if result.Error != nil { + logger.Error("Failed to query item: %v", result.Error) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + groupInfo := APIGroupInfo{ + GroupID: int64(item.GroupID), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(groupInfo) +} + +func StartAPIServer(port string) { + mux := http.NewServeMux() + mux.HandleFunc("/api/statistics", handleStatistics) + mux.HandleFunc("/api/search", handleSearch) + mux.HandleFunc("/api/items/names", handleItemNames) + mux.HandleFunc("/api/items/", handleItemGroup) + + logger.Info("Starting API server on port %s", port) + if err := http.ListenAndServe(":"+port, mux); err != nil { + logger.Error("Failed to start API server: %v", err) + } +} diff --git a/db.go b/db.go index 5f8e2c2..8ca16c1 100644 --- a/db.go +++ b/db.go @@ -614,7 +614,7 @@ func (db *DBWrapper) calculateModuleStats(params QueryParams, shipTypeIDs []int6 // Use shipTypeIDs filter instead of huge IN clause of killmailIDs var shipPlaceholders []string var args []interface{} - + if len(shipTypeIDs) > 0 { shipPlaceholders = make([]string, len(shipTypeIDs)) for i, shipID := range shipTypeIDs { diff --git a/main.go b/main.go index 4286dd9..5916aef 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,8 @@ import ( func main() { ingest := flag.Bool("ingest", false, "ingest killmails from data directory") + server := flag.Bool("server", false, "start API server") + port := flag.String("port", "8080", "API server port") flag.Parse() logger.InitFlag() logger.Default = logger.Default.ToFile("zkill.log") @@ -30,11 +32,16 @@ func main() { if *ingest { DoIngest() + return + } + + if *server { + StartAPIServer(*port) + return } logger.Info("Querying fits") - params := QueryParams{ - } + params := QueryParams{} stats, err := db.QueryFits(params) if err != nil { logger.Error("Failed to query fits: %v", err)