175 lines
5.0 KiB
Go
175 lines
5.0 KiB
Go
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"`
|
|
AttributesToRetrieve []string `json:"attributesToRetrieve"`
|
|
}
|
|
|
|
// MultiResult — ответ на один запрос: id найденных документов и оценка общего числа.
|
|
type MultiResult struct {
|
|
IDs []string
|
|
Total int
|
|
}
|
|
|
|
// MultiSearch выполняет несколько запросов за один вызов.
|
|
func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiResult, error) {
|
|
var res struct {
|
|
Results []struct {
|
|
Hits []struct {
|
|
ID string `json:"id"`
|
|
} `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)
|
|
}
|
|
out[i].Total = r.EstimatedTotalHits
|
|
}
|
|
return out, nil
|
|
}
|