Skip to content

Commit de4f51a

Browse files
authored
Hide more HTTP details in the public API (#28)
Before we release a stable API, we'd like to hide as much of the HTTP details from the public API. Changes: - Rename `Client` to `HTTPClient` - Rename `ClientOptions` to `HTTPClientOptions` - Rename `NewClient` to `NewHTTPClient` - Hide `http.Response` from `UnexpectedResponseError`, it can now be obtained by casting the `Details` field - `OperationCompletionSuccessful` now uses `nexus.Header` and `nexus.Reader` instead of `http.Header` and `io.Reader` - `OperationCompletionUnsuccessul` now uses a `nexus.Header` instead of `http.Header` - Rename `OperationCompletion(Un)Successful(Options)` `StartLinks` to `Links` 💥 **This is a breaking change** and requires a semver minor bump (we're still in 0 minor version) before release.
1 parent 5f4d7fc commit de4f51a

11 files changed

+108
-93
lines changed

README.md

+5-5
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ import (
3535

3636
### Client
3737

38-
The Nexus Client is used to start operations and get [handles](#operationhandle) to existing, asynchronous operations.
38+
The Nexus HTTPClient is used to start operations and get [handles](#operationhandle) to existing, asynchronous operations.
3939

40-
#### Create a Client
40+
#### Create an HTTPClient
4141

4242
```go
43-
client, err := nexus.NewClient(nexus.ClientOptions{
43+
client, err := nexus.NewHTTPClient(nexus.HTTPClientOptions{
4444
BaseURL: "https://example.com/path/to/my/services",
4545
Service: "example-service",
4646
})
@@ -86,7 +86,7 @@ result, err := client.StartOperation(ctx, "example", MyInput{Field: "value"}, ne
8686

8787
#### Start an Operation and Await its Completion
8888

89-
The Client provides the `ExecuteOperation` helper function as a shorthand for `StartOperation` and issuing a `GetResult`
89+
The HTTPClient provides the `ExecuteOperation` helper function as a shorthand for `StartOperation` and issuing a `GetResult`
9090
in case the operation is asynchronous.
9191

9292
```go
@@ -201,7 +201,7 @@ with the `NewOperationCompletionSuccessful` helper.
201201
Custom HTTP headers may be provided via `OperationCompletionSuccessful.Header`.
202202

203203
```go
204-
completion, _ := nexus.NewOperationCompletionSuccessful(MyStruct{Field: "value"})
204+
completion, _ := nexus.NewOperationCompletionSuccessful(MyStruct{Field: "value"}, OperationCompletionSuccessfulOptions{})
205205
request, _ := nexus.NewCompletionHTTPRequest(ctx, callbackURL, completion)
206206
response, _ := http.DefaultClient.Do(request)
207207
defer response.Body.Close()

nexus/client.go

+22-20
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import (
1717
"github.com/google/uuid"
1818
)
1919

20-
// ClientOptions are options for creating a Client.
21-
type ClientOptions struct {
20+
// HTTPClientOptions are options for creating an [HTTPClient].
21+
type HTTPClientOptions struct {
2222
// Base URL for all requests. Required.
2323
BaseURL string
2424
// Service name. Required.
@@ -46,10 +46,12 @@ var errOperationWaitTimeout = errors.New("operation wait timeout")
4646
type UnexpectedResponseError struct {
4747
// Error message.
4848
Message string
49-
// The HTTP response. The response body will have already been read into memory and does not need to be closed.
50-
Response *http.Response
51-
// Optional failure that may have been emedded in the HTTP response body.
49+
// Optional failure that may have been emedded in the response.
5250
Failure *Failure
51+
// Additional transport specific details.
52+
// For HTTP, this would include the HTTP response. The response body will have already been read into memory and
53+
// does not need to be closed.
54+
Details any
5355
}
5456

5557
// Error implements the error interface.
@@ -66,31 +68,31 @@ func newUnexpectedResponseError(message string, response *http.Response, body []
6668
}
6769

6870
return &UnexpectedResponseError{
69-
Message: message,
70-
Response: response,
71-
Failure: failure,
71+
Message: message,
72+
Details: response,
73+
Failure: failure,
7274
}
7375
}
7476

75-
// A Client makes Nexus service requests as defined in the [Nexus HTTP API].
77+
// An HTTPClient makes Nexus service requests as defined in the [Nexus HTTP API].
7678
//
7779
// It can start a new operation and get an [OperationHandle] to an existing, asynchronous operation.
7880
//
7981
// Use an [OperationHandle] to cancel, get the result of, and get information about asynchronous operations.
8082
//
81-
// OperationHandles can be obtained either by starting new operations or by calling [Client.NewHandle] for existing
83+
// OperationHandles can be obtained either by starting new operations or by calling [HTTPClient.NewHandle] for existing
8284
// operations.
8385
//
8486
// [Nexus HTTP API]: https://github.com/nexus-rpc/api
85-
type Client struct {
87+
type HTTPClient struct {
8688
// The options this client was created with after applying defaults.
87-
options ClientOptions
89+
options HTTPClientOptions
8890
serviceBaseURL *url.URL
8991
}
9092

91-
// NewClient creates a new [Client] from provided [ClientOptions].
93+
// NewHTTPClient creates a new [HTTPClient] from provided [HTTPClientOptions].
9294
// BaseURL and Service are required.
93-
func NewClient(options ClientOptions) (*Client, error) {
95+
func NewHTTPClient(options HTTPClientOptions) (*HTTPClient, error) {
9496
if options.HTTPCaller == nil {
9597
options.HTTPCaller = http.DefaultClient.Do
9698
}
@@ -113,13 +115,13 @@ func NewClient(options ClientOptions) (*Client, error) {
113115
options.Serializer = defaultSerializer
114116
}
115117

116-
return &Client{
118+
return &HTTPClient{
117119
options: options,
118120
serviceBaseURL: baseURL,
119121
}, nil
120122
}
121123

122-
// ClientStartOperationResult is the return type of [Client.StartOperation].
124+
// ClientStartOperationResult is the return type of [HTTPClient.StartOperation].
123125
// One and only one of Successful or Pending will be non-nil.
124126
type ClientStartOperationResult[T any] struct {
125127
// Set when start completes synchronously and successfully.
@@ -149,7 +151,7 @@ type ClientStartOperationResult[T any] struct {
149151
// [UnsuccessfulOperationError].
150152
//
151153
// 4. Any other error.
152-
func (c *Client) StartOperation(
154+
func (c *HTTPClient) StartOperation(
153155
ctx context.Context,
154156
operation string,
155157
input any,
@@ -279,7 +281,7 @@ func (c *Client) StartOperation(
279281
}
280282
}
281283

282-
// ExecuteOperationOptions are options for [Client.ExecuteOperation].
284+
// ExecuteOperationOptions are options for [HTTPClient.ExecuteOperation].
283285
type ExecuteOperationOptions struct {
284286
// Callback URL to provide to the handle for receiving async operation completions. Optional.
285287
// Even though Client.ExecuteOperation waits for operation completion, some applications may want to set this
@@ -321,7 +323,7 @@ type ExecuteOperationOptions struct {
321323
//
322324
// ⚠️ If this method completes successfully, the returned response's body must be read in its entirety and closed to
323325
// free up the underlying connection.
324-
func (c *Client) ExecuteOperation(ctx context.Context, operation string, input any, options ExecuteOperationOptions) (*LazyValue, error) {
326+
func (c *HTTPClient) ExecuteOperation(ctx context.Context, operation string, input any, options ExecuteOperationOptions) (*LazyValue, error) {
325327
so := StartOperationOptions{
326328
CallbackURL: options.CallbackURL,
327329
CallbackHeader: options.CallbackHeader,
@@ -351,7 +353,7 @@ func (c *Client) ExecuteOperation(ctx context.Context, operation string, input a
351353
// NewHandle gets a handle to an asynchronous operation by name and ID.
352354
// Does not incur a trip to the server.
353355
// Fails if provided an empty operation or ID.
354-
func (c *Client) NewHandle(operation string, operationID string) (*OperationHandle[*LazyValue], error) {
356+
func (c *HTTPClient) NewHandle(operation string, operationID string) (*OperationHandle[*LazyValue], error) {
355357
var es []error
356358
if operation == "" {
357359
es = append(es, errEmptyOperationName)

nexus/client_example_test.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ type MyStruct struct {
1313
}
1414

1515
var ctx = context.Background()
16-
var client *nexus.Client
16+
var client *nexus.HTTPClient
1717

18-
func ExampleClient_StartOperation() {
18+
func ExampleHTTPClient_StartOperation() {
1919
result, err := client.StartOperation(ctx, "example", MyStruct{Field: "value"}, nexus.StartOperationOptions{})
2020
if err != nil {
2121
var unsuccessfulOperationError *nexus.UnsuccessfulOperationError
@@ -40,7 +40,7 @@ func ExampleClient_StartOperation() {
4040
}
4141
}
4242

43-
func ExampleClient_ExecuteOperation() {
43+
func ExampleHTTPClient_ExecuteOperation() {
4444
response, err := client.ExecuteOperation(ctx, "operation name", MyStruct{Field: "value"}, nexus.ExecuteOperationOptions{})
4545
if err != nil {
4646
// handle nexus.UnsuccessfulOperationError, nexus.ErrOperationStillRunning and, context.DeadlineExceeded

nexus/client_test.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,22 @@ import (
1010
func TestNewClient(t *testing.T) {
1111
var err error
1212

13-
_, err = NewClient(ClientOptions{BaseURL: "", Service: "ignored"})
13+
_, err = NewHTTPClient(HTTPClientOptions{BaseURL: "", Service: "ignored"})
1414
require.ErrorContains(t, err, "empty BaseURL")
1515

16-
_, err = NewClient(ClientOptions{BaseURL: "-http://invalid", Service: "ignored"})
16+
_, err = NewHTTPClient(HTTPClientOptions{BaseURL: "-http://invalid", Service: "ignored"})
1717
var urlError *url.Error
1818
require.ErrorAs(t, err, &urlError)
1919

20-
_, err = NewClient(ClientOptions{BaseURL: "smtp://example.com", Service: "ignored"})
20+
_, err = NewHTTPClient(HTTPClientOptions{BaseURL: "smtp://example.com", Service: "ignored"})
2121
require.ErrorContains(t, err, "invalid URL scheme: smtp")
2222

23-
_, err = NewClient(ClientOptions{BaseURL: "http://example.com", Service: "ignored"})
23+
_, err = NewHTTPClient(HTTPClientOptions{BaseURL: "http://example.com", Service: "ignored"})
2424
require.NoError(t, err)
2525

26-
_, err = NewClient(ClientOptions{BaseURL: "https://example.com", Service: ""})
26+
_, err = NewHTTPClient(HTTPClientOptions{BaseURL: "https://example.com", Service: ""})
2727
require.ErrorContains(t, err, "empty Service")
2828

29-
_, err = NewClient(ClientOptions{BaseURL: "https://example.com", Service: "valid"})
29+
_, err = NewHTTPClient(HTTPClientOptions{BaseURL: "https://example.com", Service: "valid"})
3030
require.NoError(t, err)
3131
}

nexus/completion.go

+50-38
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"io"
88
"log/slog"
9+
"maps"
910
"net/http"
1011
"strconv"
1112
"time"
@@ -34,16 +35,19 @@ type OperationCompletion interface {
3435
// OperationCompletionSuccessful is input for [NewCompletionHTTPRequest], used to deliver successful operation results.
3536
type OperationCompletionSuccessful struct {
3637
// Header to send in the completion request.
37-
Header http.Header
38-
// Body to send in the completion HTTP request.
39-
// If it implements `io.Closer` it will automatically be closed by the client.
40-
Body io.Reader
38+
// Note that this is a Nexus header, not an HTTP header.
39+
Header Header
40+
41+
// A [Reader] that may be directly set on the completion or constructed when instantiating via
42+
// [NewOperationCompletionSuccessful].
43+
// Automatically closed when the completion is delivered.
44+
Reader *Reader
4145
// OperationID is the unique ID for this operation. Used when a completion callback is received before a started response.
4246
OperationID string
4347
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
4448
StartTime time.Time
45-
// StartLinks are used to link back to the operation when a completion callback is received before a started response.
46-
StartLinks []Link
49+
// Links are used to link back to the operation when a completion callback is received before a started response.
50+
Links []Link
4751
}
4852

4953
// OperationCompletionSuccessfulOptions are options for [NewOperationCompletionSuccessful].
@@ -55,48 +59,56 @@ type OperationCompletionSuccessfulOptions struct {
5559
OperationID string
5660
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
5761
StartTime time.Time
58-
// StartLinks are used to link back to the operation when a completion callback is received before a started response.
59-
StartLinks []Link
62+
// Links are used to link back to the operation when a completion callback is received before a started response.
63+
Links []Link
6064
}
6165

6266
// NewOperationCompletionSuccessful constructs an [OperationCompletionSuccessful] from a given result.
6367
func NewOperationCompletionSuccessful(result any, options OperationCompletionSuccessfulOptions) (*OperationCompletionSuccessful, error) {
64-
if reader, ok := result.(*Reader); ok {
65-
return &OperationCompletionSuccessful{
66-
Header: addContentHeaderToHTTPHeader(reader.Header, make(http.Header)),
67-
Body: reader.ReadCloser,
68-
OperationID: options.OperationID,
69-
StartTime: options.StartTime,
70-
StartLinks: options.StartLinks,
71-
}, nil
72-
} else {
68+
reader, ok := result.(*Reader)
69+
if !ok {
7370
content, ok := result.(*Content)
7471
if !ok {
75-
var err error
7672
serializer := options.Serializer
7773
if serializer == nil {
7874
serializer = defaultSerializer
7975
}
76+
var err error
8077
content, err = serializer.Serialize(result)
8178
if err != nil {
8279
return nil, err
8380
}
8481
}
85-
header := http.Header{"Content-Length": []string{strconv.Itoa(len(content.Data))}}
82+
header := maps.Clone(content.Header)
83+
if header == nil {
84+
header = make(Header, 1)
85+
}
86+
header["length"] = strconv.Itoa(len(content.Data))
8687

87-
return &OperationCompletionSuccessful{
88-
Header: addContentHeaderToHTTPHeader(content.Header, header),
89-
Body: bytes.NewReader(content.Data),
90-
OperationID: options.OperationID,
91-
StartTime: options.StartTime,
92-
StartLinks: options.StartLinks,
93-
}, nil
88+
reader = &Reader{
89+
Header: header,
90+
ReadCloser: io.NopCloser(bytes.NewReader(content.Data)),
91+
}
9492
}
93+
94+
return &OperationCompletionSuccessful{
95+
Header: make(Header),
96+
Reader: reader,
97+
OperationID: options.OperationID,
98+
StartTime: options.StartTime,
99+
Links: options.Links,
100+
}, nil
95101
}
96102

97103
func (c *OperationCompletionSuccessful) applyToHTTPRequest(request *http.Request) error {
104+
if request.Header == nil {
105+
request.Header = make(http.Header, len(c.Header)+len(c.Reader.Header)+1) // +1 for headerOperationState
106+
}
107+
if c.Reader.Header != nil {
108+
addContentHeaderToHTTPHeader(c.Reader.Header, request.Header)
109+
}
98110
if c.Header != nil {
99-
request.Header = c.Header.Clone()
111+
addNexusHeaderToHTTPHeader(c.Header, request.Header)
100112
}
101113
request.Header.Set(headerOperationState, string(OperationStateSucceeded))
102114
if c.Header.Get(HeaderOperationID) == "" && c.OperationID != "" {
@@ -106,39 +118,39 @@ func (c *OperationCompletionSuccessful) applyToHTTPRequest(request *http.Request
106118
request.Header.Set(headerOperationStartTime, c.StartTime.Format(http.TimeFormat))
107119
}
108120
if c.Header.Get(headerLink) == "" {
109-
if err := addLinksToHTTPHeader(c.StartLinks, request.Header); err != nil {
121+
if err := addLinksToHTTPHeader(c.Links, request.Header); err != nil {
110122
return err
111123
}
112124
}
113125

114-
if closer, ok := c.Body.(io.ReadCloser); ok {
115-
request.Body = closer
116-
} else {
117-
request.Body = io.NopCloser(c.Body)
118-
}
126+
request.Body = c.Reader.ReadCloser
119127
return nil
120128
}
121129

122130
// OperationCompletionUnsuccessful is input for [NewCompletionHTTPRequest], used to deliver unsuccessful operation
123131
// results.
124132
type OperationCompletionUnsuccessful struct {
125133
// Header to send in the completion request.
126-
Header http.Header
134+
// Note that this is a Nexus header, not an HTTP header.
135+
Header Header
127136
// State of the operation, should be failed or canceled.
128137
State OperationState
129138
// OperationID is the unique ID for this operation. Used when a completion callback is received before a started response.
130139
OperationID string
131140
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
132141
StartTime time.Time
133-
// StartLinks are used to link back to the operation when a completion callback is received before a started response.
134-
StartLinks []Link
142+
// Links are used to link back to the operation when a completion callback is received before a started response.
143+
Links []Link
135144
// Failure object to send with the completion.
136145
Failure *Failure
137146
}
138147

139148
func (c *OperationCompletionUnsuccessful) applyToHTTPRequest(request *http.Request) error {
149+
if request.Header == nil {
150+
request.Header = make(http.Header, len(c.Header)+2) // +2 for headerOperationState and content-type
151+
}
140152
if c.Header != nil {
141-
request.Header = c.Header.Clone()
153+
addNexusHeaderToHTTPHeader(c.Header, request.Header)
142154
}
143155
request.Header.Set(headerOperationState, string(c.State))
144156
request.Header.Set("Content-Type", contentTypeJSON)
@@ -149,7 +161,7 @@ func (c *OperationCompletionUnsuccessful) applyToHTTPRequest(request *http.Reque
149161
request.Header.Set(headerOperationStartTime, c.StartTime.Format(http.TimeFormat))
150162
}
151163
if c.Header.Get(headerLink) == "" {
152-
if err := addLinksToHTTPHeader(c.StartLinks, request.Header); err != nil {
164+
if err := addLinksToHTTPHeader(c.Links, request.Header); err != nil {
153165
return err
154166
}
155167
}

0 commit comments

Comments
 (0)