Skip to content

Commit 983f252

Browse files
authored
Add generic Do[T] function for compile-time pointer enforcement (#809)
* Skip shard routing tests on OpenSearch < 2.2.0 with security OpenSearch < 2.2.0 with the security plugin throws java.io.OptionalDataException on shard-routed requests due to non-thread-safe HashSet/HashMap in User serialization. Fixed in 2.2.0 by opensearch-project/security#1970 (50a94b47). Widen the existing 2.1.0-only skip to cover all versions below 2.2.0. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> * Add generic Do[T] function for compile-time pointer enforcement Client.Do() accepts `any` for its dataPointer parameter, deferring pointer validation to json.Unmarshal at runtime. Add a top-level opensearch.Do[T]() function whose *T signature catches non-pointer arguments at compile time. The generic wrapper is trivially inlined by the compiler -- zero runtime overhead. Mark Client.Do() with a Deprecated doc annotation to nudge callers toward the safer alternative via staticcheck SA1019 and IDE tooling. The method remains fully functional and will not be removed. Convert the unexported `do` method in opensearchapi, plugins/security, and plugins/ism to package-level generic functions, enforcing pointer safety across all internal call sites. Fixes: #808 Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> * Improve transport test assertions Replace magic status codes with http.StatusOK constants, use t.Context() instead of manual context.WithCancel, and prefer require.Len/require.Positive over less specific assertions. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> * Use require.Error/require.NoError in ParseError test Replace require.NotNil/require.Nil with the semantically correct error assertion helpers. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> * Add NoBody marker type and handle nil-body error responses Introduce opensearch.NoBody as a marker type for Do[T] calls that expect no response body, replacing the separate doRequest() function with a unified dispatch path through the generic function. Guard Do[T] against typed-nil pointers that would otherwise widen into a non-nil any interface and reach json.Unmarshal. When dataPointer is nil and the error response has no body, return a quoted-status error instead of the misleading ErrUnexpectedEmptyBody. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> --------- Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 1dbe719 commit 983f252

70 files changed

Lines changed: 356 additions & 236 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
77
### Added
88

99
- Add `primary_terms_map` and `split_shards_metadata` fields to ClusterState index metadata for OpenSearch >=3.6.0 compatibility
10+
- Add generic `opensearch.Do[T]()` function for compile-time pointer enforcement on response types, preventing a class of bugs where non-pointer values are silently passed to `Client.Do()` and fail at runtime during JSON unmarshaling. Includes `opensearch.NoBody` marker type for calls that expect no response body, unifying all internal dispatch through a single generic path ([#809](https://github.com/opensearch-project/opensearch-go/pull/809))
1011
- Add `InsecureSkipVerify` config option to disable TLS certificate verification without constructing a custom `http.Transport`, preserving `DefaultTransport` connection pooling, HTTP/2, and timeout defaults ([#786](https://github.com/opensearch-project/opensearch-go/issues/786))
1112
- Add `DisableResponseBuffering` config option to skip eager `io.ReadAll` buffering of response bodies in `Perform()`, reducing per-request allocations and TTFB for proxy and streaming use cases ([#786](https://github.com/opensearch-project/opensearch-go/issues/786))
1213
- Add per-attempt `RequestTimeout` to bound individual HTTP round-trips, preventing indefinite hangs on stalled connections ([#786](https://github.com/opensearch-project/opensearch-go/issues/786))
@@ -153,13 +154,16 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
153154

154155
### Deprecated
155156

157+
- Mark `Client.Do()` with a `Deprecated` doc annotation in favor of `opensearch.Do[T]()` for compile-time pointer safety; `Client.Do()` remains fully functional and will not be removed, but `staticcheck` SA1019 will nudge cross-package callers toward the safer generic alternative
158+
156159
### Removed
157160

158161
### Fixed
159162

160163
- Fix bulk indexer HTML-escaping `_id` and `routing` values containing `<`, `>`, or `&` characters, causing OpenSearch to store escaped values (e.g., `\u003croot_account\u003e` stored instead of `<root_account>`), leading to duplicate documents, unreachable data on read-by-ID paths, and potential shard routing mismatches. Present since the `json.Marshal` migration in 2021 (commit `3da59092`). Replace `json.Marshal` with `json.NewEncoder` + `SetEscapeHTML(false)` in `opensearchutil.worker.writeMeta` and `opensearchutil.JSONReader`; replace per-worker `aux []byte` with `sync.Pool`-backed `*bytes.Buffer`; add table-driven test coverage for `writeMeta` edge cases and refactor remaining `TestBulkIndexer` subtests to table-driven `require`-based style ([#824](https://github.com/opensearch-project/opensearch-go/pull/824))
161164
- Fix pool replacement orphaning resurrection goroutines during node discovery, causing connections to become permanently dead with no active health checker ([#786](https://github.com/opensearch-project/opensearch-go/pull/786))
162165
- Extract `newMultiServerPoolFromClientWithLock` as single source of truth for Client-to-pool settings propagation ([#786](https://github.com/opensearch-project/opensearch-go/pull/786))
166+
- Skip shard routing integration tests on OpenSearch < 2.2.0 with security plugin due to server-side `OptionalDataException` from non-thread-safe User serialization (opensearch-project/security#1970)
163167
- Fix discovery pool wipe when all cluster nodes time out during `/_nodes/http` fan-out: parse `_nodes` metadata envelope and return `errDiscoveryEmpty` when `successful == 0`, preserving the existing connection pool for retry ([#821](https://github.com/opensearch-project/opensearch-go/pull/821))
164168
- Fix flaky `TestDefaultHealthCheck_RetryAfterMaxRetry`: replace wall-clock `time.Sleep` + `atomic.Int64` synchronization with context cancellation (`ctx.Done()`), and widen `maxRetryClusterHealth` to 5s so the baseline HTTP round-trip cannot race past the retry interval ([#787](https://github.com/opensearch-project/opensearch-go/pull/787))
165169
- Skip opensearchtransport integration tests on OpenSearch < 2.2.0 with security plugin due to server-side `OptionalDataException` from non-thread-safe User serialization (opensearch-project/security#1970)

USER_GUIDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,7 @@ All `OPENSEARCH_GO_*` environment variables are evaluated once at client initial
517517
- [Advanced Index Actions](guides/advanced_index_actions.md)
518518
- [Index Templates](guides/index_template.md)
519519
- [Data Streams](guides/data_streams.md)
520+
- [Making Raw JSON REST Requests](guides/json.md)
520521
- [Request Routing](guides/routing.md)
521522
- [Cluster Health Checking](guides/cluster_health_checking.md)
522523
- [Node Discovery and Role Management](guides/node_discovery_and_roles.md)

error_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,11 +318,11 @@ func TestError(t *testing.T) {
318318

319319
// Parse the error
320320
err := opensearch.ParseError(resp)
321-
require.NotNil(t, err)
321+
require.Error(t, err)
322322

323323
// Verify the body is still readable after ParseError
324324
body, readErr := io.ReadAll(resp.Body)
325-
require.Nil(t, readErr, "body should be readable after ParseError")
325+
require.NoError(t, readErr, "body should be readable after ParseError")
326326
require.NotEmpty(t, body, "body should not be empty after ParseError")
327327

328328
// Verify the body content matches the original

guides/json.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
- [Making Raw JSON REST Requests](#making-raw-json-rest-requests)
22
- [Setup](#setup)
3+
- [Using Do for Typed Responses](#using-do-for-typed-responses)
34
- [GET](#get)
45
- [PUT](#put)
56
- [POST](#post)
@@ -17,12 +18,14 @@ Let's create a client instance:
1718
package main
1819

1920
import (
21+
"context"
2022
"fmt"
2123
"io"
2224
"net/http"
2325
"os"
2426
"strings"
2527

28+
"github.com/opensearch-project/opensearch-go/v4"
2629
"github.com/opensearch-project/opensearch-go/v4/opensearchapi"
2730
)
2831

@@ -40,6 +43,63 @@ func example() error {
4043
}
4144
```
4245
46+
## Using Do for Typed Responses
47+
48+
When you need to call an API that `opensearchapi` doesn't cover — plugin endpoints, newly released server APIs, or internal custom endpoints — use `opensearch.Do()` to execute a request and automatically unmarshal the JSON response into a struct.
49+
50+
The `Client.Do()` method accepts `any` for its response parameter, which means passing a non-pointer compiles but fails at runtime during JSON unmarshaling. The generic `opensearch.Do[T]()` function catches this mistake at compile time. `Client.Do()` is marked with a `Deprecated` doc annotation to steer callers toward the safer alternative — it remains fully functional and will not be removed, but `staticcheck` SA1019 will flag cross-package usage as a nudge.
51+
52+
First, define a request type that satisfies `opensearch.Request`:
53+
54+
```go
55+
// customReq wraps opensearch.BuildRequest to satisfy the opensearch.Request interface.
56+
type customReq struct {
57+
method string
58+
path string
59+
body io.Reader
60+
}
61+
62+
func (r customReq) GetRequest() (*http.Request, error) {
63+
return opensearch.BuildRequest(r.method, r.path, r.body, nil, nil)
64+
}
65+
```
66+
67+
Then use `opensearch.Do` to call the endpoint with a typed response:
68+
69+
```go
70+
type PluginStatusResp struct {
71+
Status string `json:"status"`
72+
Version string `json:"version"`
73+
}
74+
75+
ctx := context.Background()
76+
77+
// Preferred: opensearch.Do[T] enforces *T at compile time.
78+
var pluginStatus PluginStatusResp
79+
req := customReq{method: http.MethodGet, path: "/_plugins/my_plugin/status"}
80+
resp, err := opensearch.Do(ctx, client.Client, req, &pluginStatus)
81+
if err != nil {
82+
return err
83+
}
84+
fmt.Printf("plugin status: %s (v%s), http: %d\n", pluginStatus.Status, pluginStatus.Version, resp.StatusCode)
85+
```
86+
87+
If you pass a non-pointer value to `opensearch.Do`, the compiler rejects it:
88+
89+
```go
90+
// Compile error: cannot use pluginStatus (variable of type PluginStatusResp)
91+
// as *PluginStatusResp value in argument to opensearch.Do
92+
resp, err := opensearch.Do(ctx, client.Client, req, pluginStatus)
93+
```
94+
95+
The three levels of the client API, from lowest to highest:
96+
97+
| Level | Function | Response handling | When to use |
98+
| ----- | ------------------------------------------------------------------ | --------------------------------------------------------- | ---------------------------------------------------------- |
99+
| Low | `client.Perform(req)` | Raw `*http.Response`; caller reads and closes body | Proxying, streaming, full control needed |
100+
| Mid | `opensearch.Do(ctx, client, req, &resp)` | Automatic JSON unmarshal with compile-time pointer safety | Plugin APIs, unsupported endpoints, custom `Request` types |
101+
| High | `client.Search(ctx, req)` / `client.Indices.Create(ctx, req)` etc. | Fully typed request and response | Standard OpenSearch APIs |
102+
43103
## GET
44104
45105
The following example returns the server version information via `GET /`.

opensearch.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,12 @@ func (c *Client) Perform(req *http.Request) (*http.Response, error) {
381381
return c.Transport.Perform(req)
382382
}
383383

384-
// Do gets and performs the request. It also tries to parse the response into the dataPointer
384+
// Do gets and performs the request. It also tries to parse the response into the dataPointer.
385+
//
386+
// Deprecated: Use [Do] instead, which enforces that dataPointer is a pointer at compile time.
387+
// Client.Do accepts any, so passing a non-pointer compiles but fails at runtime during JSON
388+
// unmarshaling. The method remains fully functional and will not be removed; this annotation
389+
// exists to steer callers toward the safer generic alternative.
385390
func (c *Client) Do(ctx context.Context, req Request, dataPointer any) (*Response, error) {
386391
httpReq, err := req.GetRequest()
387392
if err != nil {
@@ -420,6 +425,24 @@ func (c *Client) Do(ctx context.Context, req Request, dataPointer any) (*Respons
420425
return response, nil
421426
}
422427

428+
// NoBody is a marker type for [Do] calls that expect no response body.
429+
// Pass (*NoBody)(nil) to skip JSON unmarshaling while retaining compile-time
430+
// pointer enforcement.
431+
type NoBody struct{}
432+
433+
// Do is a generic version of [Client.Do] that enforces dataPointer as a pointer at compile time.
434+
// It delegates to [Client.Do] after the type system has guaranteed *T.
435+
//
436+
// A nil dataPointer is forwarded as untyped nil so that [Client.Do] skips
437+
// unmarshalling. This prevents a typed nil (e.g. (*MyResp)(nil)) from being
438+
// widened into a non-nil any interface that would reach [json.Unmarshal].
439+
func Do[T any](ctx context.Context, c *Client, req Request, dataPointer *T) (*Response, error) {
440+
if dataPointer == nil {
441+
return c.Do(ctx, req, nil)
442+
}
443+
return c.Do(ctx, req, dataPointer)
444+
}
445+
423446
// Metrics returns the client metrics.
424447
func (c *Client) Metrics() (opensearchtransport.Metrics, error) {
425448
if mt, ok := c.Transport.(opensearchtransport.Measurable); ok {

opensearch_internal_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,35 @@ func TestClientInterfe(t *testing.T) {
218218
assert.NotNil(t, resp)
219219
})
220220

221+
t.Run("Generic Do()", func(t *testing.T) {
222+
c, err := NewClient(Config{Transport: mockhttp.NewRoundTripFunc(t, defaultRoundTripFunc)})
223+
require.NoError(t, err)
224+
225+
type versionInfo struct {
226+
Number string `json:"number"`
227+
Distribution string `json:"distribution"`
228+
}
229+
type rootResp struct {
230+
Version versionInfo `json:"version"`
231+
}
232+
233+
var got rootResp
234+
resp, err := Do(t.Context(), c, testReq{Path: "/"}, &got)
235+
require.NoError(t, err)
236+
require.NotNil(t, resp)
237+
require.Equal(t, "1.0.0", got.Version.Number)
238+
require.Equal(t, "opensearch", got.Version.Distribution)
239+
})
240+
241+
t.Run("Generic Do() nil NoBody pointer", func(t *testing.T) {
242+
c, err := NewClient(Config{Transport: mockhttp.NewRoundTripFunc(t, defaultRoundTripFunc)})
243+
require.NoError(t, err)
244+
245+
resp, err := Do[NoBody](t.Context(), c, testReq{Path: "/"}, nil)
246+
require.NoError(t, err)
247+
require.NotNil(t, resp)
248+
})
249+
221250
t.Run("Do() GetRequest error", func(t *testing.T) {
222251
c, err := NewClient(Config{Transport: mockhttp.NewRoundTripFunc(t, defaultRoundTripFunc)})
223252
require.NoError(t, err)

opensearchapi/api_aliases.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func (c Client) Aliases(ctx context.Context, req AliasesReq) (*AliasesResp, erro
2020
data AliasesResp
2121
err error
2222
)
23-
if data.response, err = c.do(ctx, req, &data); err != nil {
23+
if data.response, err = do(ctx, &c, req, &data); err != nil {
2424
return &data, err
2525
}
2626

opensearchapi/api_bulk.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func (c Client) Bulk(ctx context.Context, req BulkReq) (*BulkResp, error) {
2121
data BulkResp
2222
err error
2323
)
24-
if data.response, err = c.do(ctx, req, &data); err != nil {
24+
if data.response, err = do(ctx, &c, req, &data); err != nil {
2525
return &data, err
2626
}
2727

0 commit comments

Comments
 (0)