- 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>
174 lines
4.4 KiB
Go
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)
|
|
}
|
|
|