package search import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "time" ) // Meili — минимальный REST-клиент Meilisearch: только то, что нужно индексатору и поиску. type Meili struct { URL string Key string Client *http.Client } // NewMeili создаёт клиента. func NewMeili(url, key string) *Meili { return &Meili{URL: url, Key: key, Client: &http.Client{Timeout: 30 * time.Second}} } // ErrMeiliNotFound — индекс или задача не найдены. var ErrMeiliNotFound = errors.New("meilisearch: not found") func (m *Meili) do(ctx context.Context, method, path string, body, out any) error { var rd *bytes.Reader if body != nil { raw, err := json.Marshal(body) if err != nil { return err } rd = bytes.NewReader(raw) } else { rd = bytes.NewReader(nil) } req, err := http.NewRequestWithContext(ctx, method, m.URL+path, rd) if err != nil { return err } if m.Key != "" { req.Header.Set("Authorization", "Bearer "+m.Key) } req.Header.Set("Content-Type", "application/json") res, err := m.Client.Do(req) if err != nil { return fmt.Errorf("meilisearch: %w", err) } defer func() { _ = res.Body.Close() }() if res.StatusCode == http.StatusNotFound { return ErrMeiliNotFound } if res.StatusCode >= 300 { var e struct { Message string `json:"message"` Code string `json:"code"` } _ = json.NewDecoder(res.Body).Decode(&e) return fmt.Errorf("meilisearch: %s %s: %d %s %s", method, path, res.StatusCode, e.Code, e.Message) } if out != nil { return json.NewDecoder(res.Body).Decode(out) } return nil } type taskRef struct { TaskUID int64 `json:"taskUid"` } // wait ждёт завершения асинхронной задачи Meilisearch. func (m *Meili) wait(ctx context.Context, t taskRef) error { for { var task struct { Status string `json:"status"` Error *struct { Message string `json:"message"` Code string `json:"code"` } `json:"error"` } if err := m.do(ctx, http.MethodGet, fmt.Sprintf("/tasks/%d", t.TaskUID), nil, &task); err != nil { return err } switch task.Status { case "succeeded": return nil case "failed", "canceled": msg := task.Status if task.Error != nil { // Удаление отсутствующего индекса принимается (202) и падает уже в задаче. if task.Error.Code == "index_not_found" { return fmt.Errorf("%w: task %d: %s", ErrMeiliNotFound, t.TaskUID, task.Error.Message) } msg = task.Error.Message } return fmt.Errorf("meilisearch: task %d: %s", t.TaskUID, msg) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } } // call выполняет запрос, возвращающий задачу, и ждёт её. func (m *Meili) call(ctx context.Context, method, path string, body any) error { var t taskRef if err := m.do(ctx, method, path, body, &t); err != nil { return err } return m.wait(ctx, t) } // deleteIndexIfExists не создаёт заведомо провальную задачу удаления, если индекса нет. func (m *Meili) deleteIndexIfExists(ctx context.Context, uid string) error { path := "/indexes/" + uid if err := m.do(ctx, http.MethodGet, path, nil, nil); err != nil { if errors.Is(err, ErrMeiliNotFound) { return nil } return err } // Индекс мог исчезнуть между проверкой и выполнением асинхронной задачи. if err := m.call(ctx, http.MethodDelete, path, nil); err != nil && !errors.Is(err, ErrMeiliNotFound) { return err } return nil } // Healthy — Meilisearch отвечает. func (m *Meili) Healthy(ctx context.Context) error { return m.do(ctx, http.MethodGet, "/health", nil, nil) } // MultiQuery — один запрос multi-search. type MultiQuery struct { IndexUID string `json:"indexUid"` Q string `json:"q"` Limit int `json:"limit"` Offset int `json:"offset,omitempty"` MatchingStrategy string `json:"matchingStrategy,omitempty"` AttributesToRetrieve []string `json:"attributesToRetrieve"` } // MultiResult — ответ на один запрос: id найденных документов и оценка общего числа. type MultiResult struct { IDs []string Total int ExactIDs []string } // MultiSearch выполняет несколько запросов за один вызов. func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiResult, error) { var res struct { Results []struct { Hits []struct { ID string `json:"id"` Name string `json:"name"` NameTranslit any `json:"name_translit"` } `json:"hits"` EstimatedTotalHits int `json:"estimatedTotalHits"` } `json:"results"` } if err := m.do(ctx, http.MethodPost, "/multi-search", map[string]any{"queries": queries}, &res); err != nil { return nil, err } out := make([]MultiResult, len(res.Results)) for i, r := range res.Results { for _, h := range r.Hits { out[i].IDs = append(out[i].IDs, h.ID) if exactArtistName(queries[i].Q, h.Name, h.NameTranslit) { out[i].ExactIDs = append(out[i].ExactIDs, h.ID) } } out[i].Total = r.EstimatedTotalHits } return out, nil } func exactArtistName(query, name string, translit any) bool { key := searchKey(query) if key == "" { return false } if searchKey(name) == key { return true } switch v := translit.(type) { case string: // indexes built before transliteration alternatives return searchKey(v) == key case []any: for _, item := range v { if s, ok := item.(string); ok && searchKey(s) == key { return true } } } return false }