Files
defiQUG a53c15507f fix: API JSON error responses + navbar with dropdowns
- Add backend/libs/go-http-errors for consistent JSON errors
- REST API: use writeMethodNotAllowed, writeNotFound, writeInternalError
- middleware, gateway, search: use httperrors.WriteJSON
- SPA: navbar with Explore/Tools/More dropdowns, initNavDropdowns()
- Next.js: Navbar component with dropdowns + mobile menu

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 03:09:53 -08:00

174 lines
4.4 KiB
Go

package search
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/esapi"
httperrors "github.com/explorer/backend/libs/go-http-errors"
)
// SearchService handles unified search
type SearchService struct {
client *elasticsearch.Client
indexPrefix string
}
// NewSearchService creates a new search service
func NewSearchService(client *elasticsearch.Client, indexPrefix string) *SearchService {
return &SearchService{
client: client,
indexPrefix: indexPrefix,
}
}
// Search performs unified search across all indices
func (s *SearchService) Search(ctx context.Context, query string, chainID *int, limit int) ([]SearchResult, error) {
// Build search query
var indices []string
if chainID != nil {
indices = []string{
fmt.Sprintf("%s-blocks-%d", s.indexPrefix, *chainID),
fmt.Sprintf("%s-transactions-%d", s.indexPrefix, *chainID),
fmt.Sprintf("%s-addresses-%d", s.indexPrefix, *chainID),
}
} else {
// Search all chains (simplified - would need to enumerate)
indices = []string{
fmt.Sprintf("%s-blocks-*", s.indexPrefix),
fmt.Sprintf("%s-transactions-*", s.indexPrefix),
fmt.Sprintf("%s-addresses-*", s.indexPrefix),
}
}
searchQuery := map[string]interface{}{
"query": map[string]interface{}{
"multi_match": map[string]interface{}{
"query": query,
"fields": []string{"hash", "address", "from_address", "to_address"},
"type": "best_fields",
},
},
"size": limit,
}
queryJSON, _ := json.Marshal(searchQuery)
queryString := string(queryJSON)
// Execute search
req := esapi.SearchRequest{
Index: indices,
Body: strings.NewReader(queryString),
Pretty: true,
}
res, err := req.Do(ctx, s.client)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
defer res.Body.Close()
if res.IsError() {
return nil, fmt.Errorf("elasticsearch error: %s", res.String())
}
var result map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Parse results
results := []SearchResult{}
if hits, ok := result["hits"].(map[string]interface{}); ok {
if hitsList, ok := hits["hits"].([]interface{}); ok {
for _, hit := range hitsList {
if hitMap, ok := hit.(map[string]interface{}); ok {
if source, ok := hitMap["_source"].(map[string]interface{}); ok {
result := s.parseResult(source)
results = append(results, result)
}
}
}
}
}
return results, nil
}
// SearchResult represents a search result
type SearchResult struct {
Type string `json:"type"`
ChainID int `json:"chain_id"`
Data map[string]interface{} `json:"data"`
Score float64 `json:"score"`
}
func (s *SearchService) parseResult(source map[string]interface{}) SearchResult {
result := SearchResult{
Data: source,
}
if chainID, ok := source["chain_id"].(float64); ok {
result.ChainID = int(chainID)
}
// Determine type based on fields
if _, ok := source["block_number"]; ok {
result.Type = "block"
} else if _, ok := source["transaction_index"]; ok {
result.Type = "transaction"
} else if _, ok := source["address"]; ok {
result.Type = "address"
}
return result
}
// HandleSearch handles HTTP search requests
func (s *SearchService) HandleSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httperrors.WriteJSON(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed")
return
}
query := r.URL.Query().Get("q")
if query == "" {
httperrors.WriteJSON(w, http.StatusBadRequest, "BAD_REQUEST", "Query parameter 'q' is required")
return
}
var chainID *int
if chainIDStr := r.URL.Query().Get("chain_id"); chainIDStr != "" {
if id, err := strconv.Atoi(chainIDStr); err == nil {
chainID = &id
}
}
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = l
}
}
results, err := s.Search(r.Context(), query, chainID, limit)
if err != nil {
httperrors.WriteJSON(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Search failed")
return
}
response := map[string]interface{}{
"query": query,
"results": results,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}