Replace olivere/elastic with REST API client, add OpenSearch support (#37411)
Drops `github.com/olivere/elastic/v7` (unmaintained) and replaces it
with a small in-house wrapper that speaks the Elasticsearch REST API
directly via `net/http`. The subset used by Gitea (`_cluster/health`,
`_bulk`, `_doc`, `_delete_by_query`, `_refresh`, `_search`, `HEAD`/`PUT`
index) is stable across the targeted servers, so no client library is
needed.
**Targets tested**
- Elasticsearch 7, 8, 9
- OpenSearch 1, 2, 3
**Why not `go-elasticsearch`?**
The official client enforces an `X-Elastic-Product` server-identity
check that OpenSearch deliberately fails, which would force shipping a
transport shim to defeat it. Going direct over `net/http` removes that
fight along with several MB of transitive deps (`elastic-transport-go`,
`go.opentelemetry.io/otel{,/metric,/trace}`, `auto/sdk`, `easyjson`,
`intern`, `logr`, `stdr`).
Replaces: #30755
Fixes: https://github.com/go-gitea/gitea/issues/30752
---
This PR was written with the help of Claude Opus 4.7
---------
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package elasticsearch
|
||||
|
||||
// MultiMatch types used by the call sites. See
|
||||
// https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-multi-match-query.html#multi-match-types
|
||||
const (
|
||||
MultiMatchTypeBestFields = "best_fields"
|
||||
MultiMatchTypePhrasePrefix = "phrase_prefix"
|
||||
)
|
||||
|
||||
// ToAnySlice converts []T to []any for variadic query args like TermsQuery.
|
||||
func ToAnySlice[T any](s []T) []any {
|
||||
out := make([]any, len(s))
|
||||
for idx, v := range s {
|
||||
out[idx] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Query is an Elasticsearch query DSL node. It marshals to the JSON
|
||||
// object expected by the ES query API.
|
||||
type Query interface {
|
||||
querySource() map[string]any
|
||||
}
|
||||
|
||||
type rawQuery map[string]any
|
||||
|
||||
func (q rawQuery) querySource() map[string]any { return q }
|
||||
|
||||
// TermQuery matches documents whose `field` exactly equals `value`.
|
||||
func TermQuery(field string, value any) Query {
|
||||
return rawQuery{"term": map[string]any{field: value}}
|
||||
}
|
||||
|
||||
// TermsQuery matches documents whose `field` equals any of `values`.
|
||||
func TermsQuery(field string, values ...any) Query {
|
||||
return rawQuery{"terms": map[string]any{field: values}}
|
||||
}
|
||||
|
||||
// MatchQuery is a full-text match on a single field.
|
||||
func MatchQuery(field string, value any) Query {
|
||||
return rawQuery{"match": map[string]any{field: value}}
|
||||
}
|
||||
|
||||
// MatchPhraseQuery matches the exact phrase on `field`.
|
||||
func MatchPhraseQuery(field, value string) Query {
|
||||
return rawQuery{"match_phrase": map[string]any{field: value}}
|
||||
}
|
||||
|
||||
// MultiMatchQuery is the fluent builder for a multi_match query.
|
||||
type MultiMatchQuery struct {
|
||||
query any
|
||||
fields []string
|
||||
typ string
|
||||
operator string
|
||||
}
|
||||
|
||||
// NewMultiMatchQuery creates a multi_match query over the given fields.
|
||||
func NewMultiMatchQuery(query any, fields ...string) *MultiMatchQuery {
|
||||
return &MultiMatchQuery{query: query, fields: fields}
|
||||
}
|
||||
|
||||
func (m *MultiMatchQuery) Type(t string) *MultiMatchQuery { m.typ = t; return m }
|
||||
func (m *MultiMatchQuery) Operator(op string) *MultiMatchQuery { m.operator = op; return m }
|
||||
|
||||
func (m *MultiMatchQuery) querySource() map[string]any {
|
||||
body := map[string]any{"query": m.query}
|
||||
if len(m.fields) > 0 {
|
||||
body["fields"] = m.fields
|
||||
}
|
||||
if m.typ != "" {
|
||||
body["type"] = m.typ
|
||||
}
|
||||
if m.operator != "" {
|
||||
body["operator"] = m.operator
|
||||
}
|
||||
return map[string]any{"multi_match": body}
|
||||
}
|
||||
|
||||
// RangeQuery is the fluent builder for a range query.
|
||||
type RangeQuery struct {
|
||||
field string
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
func NewRangeQuery(field string) *RangeQuery {
|
||||
return &RangeQuery{field: field, body: map[string]any{}}
|
||||
}
|
||||
|
||||
func (r *RangeQuery) Gte(v any) *RangeQuery { r.body["gte"] = v; return r }
|
||||
func (r *RangeQuery) Lte(v any) *RangeQuery { r.body["lte"] = v; return r }
|
||||
|
||||
func (r *RangeQuery) querySource() map[string]any {
|
||||
return map[string]any{"range": map[string]any{r.field: r.body}}
|
||||
}
|
||||
|
||||
// BoolQuery is the fluent builder for a bool query.
|
||||
type BoolQuery struct {
|
||||
must []Query
|
||||
should []Query
|
||||
mustNot []Query
|
||||
}
|
||||
|
||||
func NewBoolQuery() *BoolQuery { return &BoolQuery{} }
|
||||
|
||||
func (b *BoolQuery) Must(q ...Query) *BoolQuery { b.must = append(b.must, q...); return b }
|
||||
func (b *BoolQuery) Should(q ...Query) *BoolQuery { b.should = append(b.should, q...); return b }
|
||||
func (b *BoolQuery) MustNot(q ...Query) *BoolQuery { b.mustNot = append(b.mustNot, q...); return b }
|
||||
|
||||
func (b *BoolQuery) querySource() map[string]any {
|
||||
body := map[string]any{}
|
||||
if len(b.must) > 0 {
|
||||
body["must"] = querySlice(b.must)
|
||||
}
|
||||
if len(b.should) > 0 {
|
||||
body["should"] = querySlice(b.should)
|
||||
}
|
||||
if len(b.mustNot) > 0 {
|
||||
body["must_not"] = querySlice(b.mustNot)
|
||||
}
|
||||
return map[string]any{"bool": body}
|
||||
}
|
||||
|
||||
func querySlice(queries []Query) []map[string]any {
|
||||
out := make([]map[string]any, len(queries))
|
||||
for idx, q := range queries {
|
||||
out[idx] = q.querySource()
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user