Skip to content

Commit 5f4d7fc

Browse files
authored
Add OperationID, StartTime and StartLinks to CompletionRequest (#26)
Added `OperationID`, `StartTime` and `StartLinks` fields to `CompletionRequest`. This information is necessary to fabricate a start event if the completion is received before the response to the StartOperation request. Bumps SDK version to `0.0.12`
1 parent 8edd3bd commit 5f4d7fc

File tree

3 files changed

+130
-13
lines changed

3 files changed

+130
-13
lines changed

Diff for: nexus/api.go

+8-5
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@ import (
1818
)
1919

2020
// Package version.
21-
const version = "v0.0.11"
21+
const version = "v0.0.12"
2222

2323
const (
2424
// Nexus specific headers.
25-
headerOperationState = "nexus-operation-state"
26-
headerOperationID = "nexus-operation-id"
27-
headerRequestID = "nexus-request-id"
28-
headerLink = "nexus-link"
25+
headerOperationState = "nexus-operation-state"
26+
headerRequestID = "nexus-request-id"
27+
headerLink = "nexus-link"
28+
headerOperationStartTime = "nexus-operation-start-time"
29+
// HeaderOperationID is the unique ID returned by the StartOperation response for async operations.
30+
// Must be set on callback headers to support completing operations before the start response is received.
31+
HeaderOperationID = "nexus-operation-id"
2932

3033
// HeaderRequestTimeout is the total time to complete a Nexus HTTP request.
3134
HeaderRequestTimeout = "request-timeout"

Diff for: nexus/completion.go

+71-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"log/slog"
99
"net/http"
1010
"strconv"
11+
"time"
1112
)
1213

1314
// NewCompletionHTTPRequest creates an HTTP request deliver an operation completion to a given URL.
@@ -37,21 +38,36 @@ type OperationCompletionSuccessful struct {
3738
// Body to send in the completion HTTP request.
3839
// If it implements `io.Closer` it will automatically be closed by the client.
3940
Body io.Reader
41+
// OperationID is the unique ID for this operation. Used when a completion callback is received before a started response.
42+
OperationID string
43+
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
44+
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
4047
}
4148

4249
// OperationCompletionSuccessfulOptions are options for [NewOperationCompletionSuccessful].
4350
type OperationCompletionSuccessfulOptions struct {
4451
// Optional serializer for the result. Defaults to the SDK's default Serializer, which handles JSONables, byte
4552
// slices and nils.
4653
Serializer Serializer
54+
// OperationID is the unique ID for this operation. Used when a completion callback is received before a started response.
55+
OperationID string
56+
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
57+
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
4760
}
4861

4962
// NewOperationCompletionSuccessful constructs an [OperationCompletionSuccessful] from a given result.
5063
func NewOperationCompletionSuccessful(result any, options OperationCompletionSuccessfulOptions) (*OperationCompletionSuccessful, error) {
5164
if reader, ok := result.(*Reader); ok {
5265
return &OperationCompletionSuccessful{
53-
Header: addContentHeaderToHTTPHeader(reader.Header, make(http.Header)),
54-
Body: reader.ReadCloser,
66+
Header: addContentHeaderToHTTPHeader(reader.Header, make(http.Header)),
67+
Body: reader.ReadCloser,
68+
OperationID: options.OperationID,
69+
StartTime: options.StartTime,
70+
StartLinks: options.StartLinks,
5571
}, nil
5672
} else {
5773
content, ok := result.(*Content)
@@ -69,8 +85,11 @@ func NewOperationCompletionSuccessful(result any, options OperationCompletionSuc
6985
header := http.Header{"Content-Length": []string{strconv.Itoa(len(content.Data))}}
7086

7187
return &OperationCompletionSuccessful{
72-
Header: addContentHeaderToHTTPHeader(content.Header, header),
73-
Body: bytes.NewReader(content.Data),
88+
Header: addContentHeaderToHTTPHeader(content.Header, header),
89+
Body: bytes.NewReader(content.Data),
90+
OperationID: options.OperationID,
91+
StartTime: options.StartTime,
92+
StartLinks: options.StartLinks,
7493
}, nil
7594
}
7695
}
@@ -80,6 +99,18 @@ func (c *OperationCompletionSuccessful) applyToHTTPRequest(request *http.Request
8099
request.Header = c.Header.Clone()
81100
}
82101
request.Header.Set(headerOperationState, string(OperationStateSucceeded))
102+
if c.Header.Get(HeaderOperationID) == "" && c.OperationID != "" {
103+
request.Header.Set(HeaderOperationID, c.OperationID)
104+
}
105+
if c.Header.Get(headerOperationStartTime) == "" && !c.StartTime.IsZero() {
106+
request.Header.Set(headerOperationStartTime, c.StartTime.Format(http.TimeFormat))
107+
}
108+
if c.Header.Get(headerLink) == "" {
109+
if err := addLinksToHTTPHeader(c.StartLinks, request.Header); err != nil {
110+
return err
111+
}
112+
}
113+
83114
if closer, ok := c.Body.(io.ReadCloser); ok {
84115
request.Body = closer
85116
} else {
@@ -95,6 +126,12 @@ type OperationCompletionUnsuccessful struct {
95126
Header http.Header
96127
// State of the operation, should be failed or canceled.
97128
State OperationState
129+
// OperationID is the unique ID for this operation. Used when a completion callback is received before a started response.
130+
OperationID string
131+
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
132+
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
98135
// Failure object to send with the completion.
99136
Failure *Failure
100137
}
@@ -105,6 +142,17 @@ func (c *OperationCompletionUnsuccessful) applyToHTTPRequest(request *http.Reque
105142
}
106143
request.Header.Set(headerOperationState, string(c.State))
107144
request.Header.Set("Content-Type", contentTypeJSON)
145+
if c.Header.Get(HeaderOperationID) == "" && c.OperationID != "" {
146+
request.Header.Set(HeaderOperationID, c.OperationID)
147+
}
148+
if c.Header.Get(headerOperationStartTime) == "" && !c.StartTime.IsZero() {
149+
request.Header.Set(headerOperationStartTime, c.StartTime.Format(http.TimeFormat))
150+
}
151+
if c.Header.Get(headerLink) == "" {
152+
if err := addLinksToHTTPHeader(c.StartLinks, request.Header); err != nil {
153+
return err
154+
}
155+
}
108156

109157
b, err := json.Marshal(c.Failure)
110158
if err != nil {
@@ -121,6 +169,12 @@ type CompletionRequest struct {
121169
HTTPRequest *http.Request
122170
// State of the operation.
123171
State OperationState
172+
// OperationID is the ID of the operation. Used when a completion callback is received before a started response.
173+
OperationID string
174+
// StartTime is the time the operation started. Used when a completion callback is received before a started response.
175+
StartTime time.Time
176+
// StartLinks are used to link back to the operation when a completion callback is received before a started response.
177+
StartLinks []Link
124178
// Parsed from request and set if State is failed or canceled.
125179
Failure *Failure
126180
// Extracted from request and set if State is succeeded.
@@ -154,8 +208,21 @@ func (h *completionHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *h
154208
ctx := request.Context()
155209
completion := CompletionRequest{
156210
State: OperationState(request.Header.Get(headerOperationState)),
211+
OperationID: request.Header.Get(HeaderOperationID),
157212
HTTPRequest: request,
158213
}
214+
if startTimeHeader := request.Header.Get(headerOperationStartTime); startTimeHeader != "" {
215+
var parseTimeErr error
216+
if completion.StartTime, parseTimeErr = http.ParseTime(startTimeHeader); parseTimeErr != nil {
217+
h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "failed to parse operation start time header"))
218+
return
219+
}
220+
}
221+
var decodeErr error
222+
if completion.StartLinks, decodeErr = getLinksFromHeader(request.Header); decodeErr != nil {
223+
h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "failed to decode links from request headers"))
224+
return
225+
}
159226
switch completion.State {
160227
case OperationStateFailed, OperationStateCanceled:
161228
if !isMediaTypeJSON(request.Header.Get("Content-Type")) {

Diff for: nexus/completion_test.go

+51-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"context"
66
"io"
77
"net/http"
8+
"net/url"
89
"testing"
10+
"time"
911

1012
"github.com/stretchr/testify/require"
1113
)
@@ -26,6 +28,12 @@ func (h *successfulCompletionHandler) CompleteOperation(ctx context.Context, com
2628
if completion.HTTPRequest.Header.Get("User-Agent") != userAgent {
2729
return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'User-Agent' header: %q", completion.HTTPRequest.Header.Get("User-Agent"))
2830
}
31+
if completion.OperationID != "test-operation-id" {
32+
return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid %q header: %q", HeaderOperationID, completion.HTTPRequest.Header.Get(HeaderOperationID))
33+
}
34+
if len(completion.StartLinks) == 0 {
35+
return HandlerErrorf(HandlerErrorTypeBadRequest, "expected StartLinks to be set on CompletionRequest")
36+
}
2937
var result int
3038
err := completion.Result.Consume(&result)
3139
if err != nil {
@@ -41,7 +49,19 @@ func TestSuccessfulCompletion(t *testing.T) {
4149
ctx, callbackURL, teardown := setupForCompletion(t, &successfulCompletionHandler{}, nil)
4250
defer teardown()
4351

44-
completion, err := NewOperationCompletionSuccessful(666, OperationCompletionSuccessfulOptions{})
52+
completion, err := NewOperationCompletionSuccessful(666, OperationCompletionSuccessfulOptions{
53+
OperationID: "test-operation-id",
54+
StartTime: time.Now(),
55+
StartLinks: []Link{{
56+
URL: &url.URL{
57+
Scheme: "https",
58+
Host: "example.com",
59+
Path: "/path/to/something",
60+
RawQuery: "param=value",
61+
},
62+
Type: "url",
63+
}},
64+
})
4565
completion.Header.Add("foo", "bar")
4666
require.NoError(t, err)
4767

@@ -55,15 +75,25 @@ func TestSuccessfulCompletion(t *testing.T) {
5575
require.Equal(t, http.StatusOK, response.StatusCode)
5676
}
5777

58-
func TestSuccessfulCompletion_CustomSerializr(t *testing.T) {
78+
func TestSuccessfulCompletion_CustomSerializer(t *testing.T) {
5979
serializer := &customSerializer{}
6080
ctx, callbackURL, teardown := setupForCompletion(t, &successfulCompletionHandler{}, serializer)
6181
defer teardown()
6282

6383
completion, err := NewOperationCompletionSuccessful(666, OperationCompletionSuccessfulOptions{
6484
Serializer: serializer,
85+
StartLinks: []Link{{
86+
URL: &url.URL{
87+
Scheme: "https",
88+
Host: "example.com",
89+
Path: "/path/to/something",
90+
RawQuery: "param=value",
91+
},
92+
Type: "url",
93+
}},
6594
})
6695
completion.Header.Add("foo", "bar")
96+
completion.Header.Add(HeaderOperationID, "test-operation-id")
6797
require.NoError(t, err)
6898

6999
request, err := NewCompletionHTTPRequest(ctx, callbackURL, completion)
@@ -92,6 +122,12 @@ func (h *failureExpectingCompletionHandler) CompleteOperation(ctx context.Contex
92122
if completion.HTTPRequest.Header.Get("foo") != "bar" {
93123
return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'foo' header: %q", completion.HTTPRequest.Header.Get("foo"))
94124
}
125+
if completion.OperationID != "test-operation-id" {
126+
return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid %q header: %q", HeaderOperationID, completion.HTTPRequest.Header.Get(HeaderOperationID))
127+
}
128+
if len(completion.StartLinks) == 0 {
129+
return HandlerErrorf(HandlerErrorTypeBadRequest, "expected StartLinks to be set on CompletionRequest")
130+
}
95131

96132
return nil
97133
}
@@ -101,8 +137,19 @@ func TestFailureCompletion(t *testing.T) {
101137
defer teardown()
102138

103139
request, err := NewCompletionHTTPRequest(ctx, callbackURL, &OperationCompletionUnsuccessful{
104-
Header: http.Header{"foo": []string{"bar"}},
105-
State: OperationStateCanceled,
140+
Header: http.Header{"foo": []string{"bar"}},
141+
State: OperationStateCanceled,
142+
OperationID: "test-operation-id",
143+
StartTime: time.Now(),
144+
StartLinks: []Link{{
145+
URL: &url.URL{
146+
Scheme: "https",
147+
Host: "example.com",
148+
Path: "/path/to/something",
149+
RawQuery: "param=value",
150+
},
151+
Type: "url",
152+
}},
106153
Failure: &Failure{
107154
Message: "expected message",
108155
},

0 commit comments

Comments
 (0)