Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hide more HTTP details in the public API #28

Merged
merged 5 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ import (

### Client

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

#### Create a Client
#### Create an HTTPClient

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

#### Start an Operation and Await its Completion

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

```go
Expand Down Expand Up @@ -201,7 +201,7 @@ with the `NewOperationCompletionSuccessful` helper.
Custom HTTP headers may be provided via `OperationCompletionSuccessful.Header`.

```go
completion, _ := nexus.NewOperationCompletionSuccessful(MyStruct{Field: "value"})
completion, _ := nexus.NewOperationCompletionSuccessful(MyStruct{Field: "value"}, OperationCompletionSuccessfulOptions{})
request, _ := nexus.NewCompletionHTTPRequest(ctx, callbackURL, completion)
response, _ := http.DefaultClient.Do(request)
defer response.Body.Close()
Expand Down
42 changes: 22 additions & 20 deletions nexus/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import (
"github.com/google/uuid"
)

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

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

return &UnexpectedResponseError{
Message: message,
Response: response,
Failure: failure,
Message: message,
Details: response,
Failure: failure,
}
}

// A Client makes Nexus service requests as defined in the [Nexus HTTP API].
// An HTTPClient makes Nexus service requests as defined in the [Nexus HTTP API].
//
// It can start a new operation and get an [OperationHandle] to an existing, asynchronous operation.
//
// Use an [OperationHandle] to cancel, get the result of, and get information about asynchronous operations.
//
// OperationHandles can be obtained either by starting new operations or by calling [Client.NewHandle] for existing
// OperationHandles can be obtained either by starting new operations or by calling [HTTPClient.NewHandle] for existing
// operations.
//
// [Nexus HTTP API]: https://github.com/nexus-rpc/api
type Client struct {
type HTTPClient struct {
// The options this client was created with after applying defaults.
options ClientOptions
options HTTPClientOptions
serviceBaseURL *url.URL
}

// NewClient creates a new [Client] from provided [ClientOptions].
// NewHTTPClient creates a new [HTTPClient] from provided [HTTPClientOptions].
// BaseURL and Service are required.
func NewClient(options ClientOptions) (*Client, error) {
func NewHTTPClient(options HTTPClientOptions) (*HTTPClient, error) {
if options.HTTPCaller == nil {
options.HTTPCaller = http.DefaultClient.Do
}
Expand All @@ -113,13 +115,13 @@ func NewClient(options ClientOptions) (*Client, error) {
options.Serializer = defaultSerializer
}

return &Client{
return &HTTPClient{
options: options,
serviceBaseURL: baseURL,
}, nil
}

// ClientStartOperationResult is the return type of [Client.StartOperation].
// ClientStartOperationResult is the return type of [HTTPClient.StartOperation].
// One and only one of Successful or Pending will be non-nil.
type ClientStartOperationResult[T any] struct {
// Set when start completes synchronously and successfully.
Expand Down Expand Up @@ -149,7 +151,7 @@ type ClientStartOperationResult[T any] struct {
// [UnsuccessfulOperationError].
//
// 4. Any other error.
func (c *Client) StartOperation(
func (c *HTTPClient) StartOperation(
ctx context.Context,
operation string,
input any,
Expand Down Expand Up @@ -279,7 +281,7 @@ func (c *Client) StartOperation(
}
}

// ExecuteOperationOptions are options for [Client.ExecuteOperation].
// ExecuteOperationOptions are options for [HTTPClient.ExecuteOperation].
type ExecuteOperationOptions struct {
// Callback URL to provide to the handle for receiving async operation completions. Optional.
// Even though Client.ExecuteOperation waits for operation completion, some applications may want to set this
Expand Down Expand Up @@ -321,7 +323,7 @@ type ExecuteOperationOptions struct {
//
// ⚠️ If this method completes successfully, the returned response's body must be read in its entirety and closed to
// free up the underlying connection.
func (c *Client) ExecuteOperation(ctx context.Context, operation string, input any, options ExecuteOperationOptions) (*LazyValue, error) {
func (c *HTTPClient) ExecuteOperation(ctx context.Context, operation string, input any, options ExecuteOperationOptions) (*LazyValue, error) {
so := StartOperationOptions{
CallbackURL: options.CallbackURL,
CallbackHeader: options.CallbackHeader,
Expand Down Expand Up @@ -351,7 +353,7 @@ func (c *Client) ExecuteOperation(ctx context.Context, operation string, input a
// NewHandle gets a handle to an asynchronous operation by name and ID.
// Does not incur a trip to the server.
// Fails if provided an empty operation or ID.
func (c *Client) NewHandle(operation string, operationID string) (*OperationHandle[*LazyValue], error) {
func (c *HTTPClient) NewHandle(operation string, operationID string) (*OperationHandle[*LazyValue], error) {
var es []error
if operation == "" {
es = append(es, errEmptyOperationName)
Expand Down
6 changes: 3 additions & 3 deletions nexus/client_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ type MyStruct struct {
}

var ctx = context.Background()
var client *nexus.Client
var client *nexus.HTTPClient

func ExampleClient_StartOperation() {
func ExampleHTTPClient_StartOperation() {
result, err := client.StartOperation(ctx, "example", MyStruct{Field: "value"}, nexus.StartOperationOptions{})
if err != nil {
var unsuccessfulOperationError *nexus.UnsuccessfulOperationError
Expand All @@ -40,7 +40,7 @@ func ExampleClient_StartOperation() {
}
}

func ExampleClient_ExecuteOperation() {
func ExampleHTTPClient_ExecuteOperation() {
response, err := client.ExecuteOperation(ctx, "operation name", MyStruct{Field: "value"}, nexus.ExecuteOperationOptions{})
if err != nil {
// handle nexus.UnsuccessfulOperationError, nexus.ErrOperationStillRunning and, context.DeadlineExceeded
Expand Down
12 changes: 6 additions & 6 deletions nexus/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,22 @@ import (
func TestNewClient(t *testing.T) {
var err error

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

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

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

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

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

_, err = NewClient(ClientOptions{BaseURL: "https://example.com", Service: "valid"})
_, err = NewHTTPClient(HTTPClientOptions{BaseURL: "https://example.com", Service: "valid"})
require.NoError(t, err)
}
88 changes: 50 additions & 38 deletions nexus/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"log/slog"
"maps"
"net/http"
"strconv"
"time"
Expand Down Expand Up @@ -34,16 +35,19 @@ type OperationCompletion interface {
// OperationCompletionSuccessful is input for [NewCompletionHTTPRequest], used to deliver successful operation results.
type OperationCompletionSuccessful struct {
// Header to send in the completion request.
Header http.Header
// Body to send in the completion HTTP request.
// If it implements `io.Closer` it will automatically be closed by the client.
Body io.Reader
// Note that this is a Nexus header, not an HTTP header.
Header Header

// A [Reader] that may be directly set on the completion or constructed when instantiating via
// [NewOperationCompletionSuccessful].
// Automatically closed when the completion is delivered.
Reader *Reader
Comment on lines +38 to +44
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't Reader already have a header on it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a content header though, not a Header for the entire request.

// OperationID is the unique ID for this operation. Used when a completion callback is received before a started response.
OperationID string
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
StartTime time.Time
// StartLinks are used to link back to the operation when a completion callback is received before a started response.
StartLinks []Link
// Links are used to link back to the operation when a completion callback is received before a started response.
Links []Link
}

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

// NewOperationCompletionSuccessful constructs an [OperationCompletionSuccessful] from a given result.
func NewOperationCompletionSuccessful(result any, options OperationCompletionSuccessfulOptions) (*OperationCompletionSuccessful, error) {
if reader, ok := result.(*Reader); ok {
return &OperationCompletionSuccessful{
Header: addContentHeaderToHTTPHeader(reader.Header, make(http.Header)),
Body: reader.ReadCloser,
OperationID: options.OperationID,
StartTime: options.StartTime,
StartLinks: options.StartLinks,
}, nil
} else {
reader, ok := result.(*Reader)
if !ok {
content, ok := result.(*Content)
if !ok {
var err error
serializer := options.Serializer
if serializer == nil {
serializer = defaultSerializer
}
var err error
content, err = serializer.Serialize(result)
if err != nil {
return nil, err
}
}
header := http.Header{"Content-Length": []string{strconv.Itoa(len(content.Data))}}
header := maps.Clone(content.Header)
if header == nil {
header = make(Header, 1)
}
header["length"] = strconv.Itoa(len(content.Data))

return &OperationCompletionSuccessful{
Header: addContentHeaderToHTTPHeader(content.Header, header),
Body: bytes.NewReader(content.Data),
OperationID: options.OperationID,
StartTime: options.StartTime,
StartLinks: options.StartLinks,
}, nil
reader = &Reader{
Header: header,
ReadCloser: io.NopCloser(bytes.NewReader(content.Data)),
}
}

return &OperationCompletionSuccessful{
Header: make(Header),
Reader: reader,
OperationID: options.OperationID,
StartTime: options.StartTime,
Links: options.Links,
}, nil
}

func (c *OperationCompletionSuccessful) applyToHTTPRequest(request *http.Request) error {
if request.Header == nil {
request.Header = make(http.Header, len(c.Header)+len(c.Reader.Header)+1) // +1 for headerOperationState
}
if c.Reader.Header != nil {
addContentHeaderToHTTPHeader(c.Reader.Header, request.Header)
}
if c.Header != nil {
request.Header = c.Header.Clone()
addNexusHeaderToHTTPHeader(c.Header, request.Header)
}
request.Header.Set(headerOperationState, string(OperationStateSucceeded))
if c.Header.Get(HeaderOperationID) == "" && c.OperationID != "" {
Expand All @@ -106,39 +118,39 @@ func (c *OperationCompletionSuccessful) applyToHTTPRequest(request *http.Request
request.Header.Set(headerOperationStartTime, c.StartTime.Format(http.TimeFormat))
}
if c.Header.Get(headerLink) == "" {
if err := addLinksToHTTPHeader(c.StartLinks, request.Header); err != nil {
if err := addLinksToHTTPHeader(c.Links, request.Header); err != nil {
return err
}
}

if closer, ok := c.Body.(io.ReadCloser); ok {
request.Body = closer
} else {
request.Body = io.NopCloser(c.Body)
}
request.Body = c.Reader.ReadCloser
return nil
}

// OperationCompletionUnsuccessful is input for [NewCompletionHTTPRequest], used to deliver unsuccessful operation
// results.
type OperationCompletionUnsuccessful struct {
// Header to send in the completion request.
Header http.Header
// Note that this is a Nexus header, not an HTTP header.
Header Header
// State of the operation, should be failed or canceled.
State OperationState
// OperationID is the unique ID for this operation. Used when a completion callback is received before a started response.
OperationID string
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
StartTime time.Time
// StartLinks are used to link back to the operation when a completion callback is received before a started response.
StartLinks []Link
// Links are used to link back to the operation when a completion callback is received before a started response.
Links []Link
// Failure object to send with the completion.
Failure *Failure
}

func (c *OperationCompletionUnsuccessful) applyToHTTPRequest(request *http.Request) error {
if request.Header == nil {
request.Header = make(http.Header, len(c.Header)+2) // +2 for headerOperationState and content-type
}
if c.Header != nil {
request.Header = c.Header.Clone()
addNexusHeaderToHTTPHeader(c.Header, request.Header)
}
request.Header.Set(headerOperationState, string(c.State))
request.Header.Set("Content-Type", contentTypeJSON)
Expand All @@ -149,7 +161,7 @@ func (c *OperationCompletionUnsuccessful) applyToHTTPRequest(request *http.Reque
request.Header.Set(headerOperationStartTime, c.StartTime.Format(http.TimeFormat))
}
if c.Header.Get(headerLink) == "" {
if err := addLinksToHTTPHeader(c.StartLinks, request.Header); err != nil {
if err := addLinksToHTTPHeader(c.Links, request.Header); err != nil {
return err
}
}
Expand Down
Loading
Loading