├── .github └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── contrib └── nexusproto │ ├── go.mod │ ├── go.sum │ ├── serializer.go │ └── serializer_test.go ├── go.mod ├── go.sum └── nexus ├── api.go ├── api_test.go ├── cancel_test.go ├── client.go ├── client_example_test.go ├── client_test.go ├── completion.go ├── completion_test.go ├── get_info_test.go ├── get_result_test.go ├── handle.go ├── handle_test.go ├── handler_context_test.go ├── handler_example_test.go ├── operation.go ├── operation_test.go ├── options.go ├── serializer.go ├── serializer_test.go ├── server.go ├── server_test.go ├── setup_test.go ├── start_test.go └── unimplemented_handler.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-lint-test: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | submodules: recursive 18 | - uses: actions/setup-go@v4 19 | with: 20 | go-version: "1.21" 21 | - name: Lint 22 | uses: golangci/golangci-lint-action@v6 23 | with: 24 | version: v1.64 25 | args: --verbose --timeout 3m --fix=false 26 | - name: Test 27 | run: go test -v ./... 28 | - name: Lint nexusproto 29 | uses: golangci/golangci-lint-action@v6 30 | with: 31 | version: v1.64 32 | args: --verbose --timeout 3m --fix=false 33 | working-directory: ./contrib/nexusproto 34 | - name: Test nexusproto 35 | run: go test -v ./... 36 | working-directory: ./contrib/nexusproto 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Temporal Technologies Inc. All Rights Reserved 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nexus Go SDK 2 | 3 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/nexus-rpc/sdk-go)](https://pkg.go.dev/github.com/nexus-rpc/sdk-go) 4 | [![Continuous Integration](https://github.com/nexus-rpc/sdk-go/actions/workflows/ci.yml/badge.svg)](https://github.com/nexus-rpc/sdk-go/actions/workflows/ci.yml) 5 | 6 | Client and server package for working with the Nexus [HTTP API](https://github.com/nexus-rpc/api). 7 | 8 | ## What is Nexus? 9 | 10 | Nexus is a synchronous RPC protocol. Arbitrary length operations are modelled on top of a set of pre-defined synchronous 11 | RPCs. 12 | 13 | A Nexus caller calls a handler. The handler may respond inline or return a reference for a future, asynchronous 14 | operation. The caller can cancel an asynchronous operation, check for its outcome, or fetch its current state. The 15 | caller can also specify a callback URL, which the handler uses to asynchronously deliver the result of an operation when 16 | it is ready. 17 | 18 | ## Installation 19 | 20 | ```shell 21 | go get -u github.com/nexus-rpc/sdk-go 22 | ``` 23 | 24 | ## Usage 25 | 26 | ### Import 27 | 28 | ```go 29 | import ( 30 | "github.com/nexus-rpc/sdk-go/nexus" 31 | ) 32 | ``` 33 | 34 | ### Client 35 | 36 | The Nexus HTTPClient is used to start operations and get [handles](#operationhandle) to existing, asynchronous operations. 37 | 38 | #### Create an HTTPClient 39 | 40 | ```go 41 | client, err := nexus.NewHTTPClient(nexus.HTTPClientOptions{ 42 | BaseURL: "https://example.com/path/to/my/services", 43 | Service: "example-service", 44 | }) 45 | ``` 46 | 47 | #### Start an Operation 48 | 49 | An OperationReference can be used to invoke an opertion in a typed way: 50 | 51 | ```go 52 | // Create an operation reference for typed invocation. 53 | // You may also use any Operation implementation for invocation (more on that below). 54 | operation := nexus.NewOperationReference[MyInput, MyOutput]("example") 55 | 56 | // StartOperationOptions can be used to explicitly set a request ID, headers, and callback URL. 57 | result, err := nexus.StartOperation(ctx, client, operation, MyInput{Field: "value"}, nexus.StartOperationOptions{}) 58 | if err != nil { 59 | var OperationError *nexus.OperationError 60 | if errors.As(err, &OperationError) { // operation failed or canceled 61 | fmt.Printf("Operation unsuccessful with state: %s, failure message: %s\n", OperationError.State, OperationError.Cause.Error()) 62 | } 63 | var handlerError *nexus.HandlerError 64 | if errors.As(err, &handlerError) { 65 | fmt.Printf("Handler returned an error, type: %s, failure message: %s\n", handlerError.Type, handlerError.Cause.Error()) 66 | } 67 | // most other errors should be returned as *nexus.UnexpectedResponseError 68 | } 69 | if result.Successful != nil { // operation successful 70 | output := result.Successful // output is of type MyOutput 71 | fmt.Printf("Operation succeeded synchronously: %v\n", output) 72 | } else { // operation started asynchronously 73 | handle := result.Pending 74 | fmt.Printf("Started asynchronous operation with token: %s\n", handle.Token) 75 | } 76 | ``` 77 | 78 | Alternatively, an operation can be started by name: 79 | 80 | ```go 81 | result, err := client.StartOperation(ctx, "example", MyInput{Field: "value"}, nexus.StartOperationOptions{}) 82 | // result.Succesful is a LazyValue that must be consumed to free up the underlying connection. 83 | ``` 84 | 85 | #### Start an Operation and Await its Completion 86 | 87 | The HTTPClient provides the `ExecuteOperation` helper function as a shorthand for `StartOperation` and issuing a `GetResult` 88 | in case the operation is asynchronous. 89 | 90 | ```go 91 | // By default ExecuteOperation will long poll until the context deadline for the operation to complete. 92 | // Set ExecuteOperationOptions.Wait to change the wait duration. 93 | output, err := nexus.ExecuteOperation(ctx, client, operation, MyInput{}, nexus.ExecuteOperationOptions{}) 94 | if err != nil { 95 | // handle nexus.OperationError, nexus.ErrOperationStillRunning, and context.DeadlineExceeded 96 | } 97 | fmt.Printf("Operation succeeded: %v\n", output) // output is of type MyOutput 98 | ``` 99 | 100 | Alternatively, an operation can be executed by name: 101 | 102 | ``` 103 | lazyValue, err := client.ExecuteOperation(ctx, "example", MyInput{}, nexus.ExecuteOperationOptions{}) 104 | // lazyValue that must be consumed to free up the underlying connection. 105 | ``` 106 | 107 | #### Get a Handle to an Existing Operation 108 | 109 | Getting a handle does not incur a trip to the server. 110 | 111 | ```go 112 | // Get a handle from an OperationReference 113 | handle, _ := nexus.NewHandle(client, operation, "operation token") 114 | 115 | // Get a handle from a string name 116 | handle, _ := client.NewHandle("operation name", "operation token") 117 | ``` 118 | 119 | ### OperationHandle 120 | 121 | `OperationHandle`s are used to cancel and get the result and status of an operation. 122 | 123 | Handles expose a couple of readonly attributes: `Operation` and `Token`. 124 | 125 | #### Operation 126 | 127 | `Operation` is the name of the operation this handle represents. 128 | 129 | #### Token 130 | 131 | `Token` is the operation token as returned by a Nexus handler in the response to `StartOperation` or set by the client 132 | in the `NewHandle` method. 133 | 134 | #### Get the Result of an Operation 135 | 136 | The `GetResult` method is used to get the result of an operation, issuing a network request to the handle's client's 137 | configured endpoint. 138 | 139 | By default, GetResult returns (nil, `ErrOperationStillRunning`) immediately after issuing a call if the operation has 140 | not yet completed. 141 | 142 | Callers may set GetOperationResultOptions.Wait to a value greater than 0 to alter this behavior, causing the client to 143 | long poll for the result issuing one or more requests until the provided wait period exceeds, in which case (nil, 144 | `ErrOperationStillRunning`) is returned. 145 | 146 | The wait time is capped to the deadline of the provided context. Make sure to handle both context deadline errors and 147 | `ErrOperationStillRunning`. 148 | 149 | Note that the wait period is enforced by the server and may not be respected if the server is misbehaving. Set the 150 | context deadline to the max allowed wait period to ensure this call returns in a timely fashion. 151 | 152 | Custom request headers may be provided via `GetOperationResultOptions`. 153 | 154 | When a handle is created from an OperationReference, `GetResult` returns a result of the reference's output type. When a 155 | handle is created from a name, `GetResult` returns a `LazyValue` which must be `Consume`d to free up the underlying 156 | connection. 157 | 158 | ```go 159 | result, err := handle.GetResult(ctx, nexus.GetOperationResultOptions{}) 160 | if err != nil { 161 | // handle nexus.OperationError, nexus.ErrOperationStillRunning, and context.DeadlineExceeded 162 | } 163 | // result's type is the Handle's generic type T. 164 | ``` 165 | 166 | #### Get Operation Information 167 | 168 | The `GetInfo` method is used to get operation information (currently only the operation's state) issuing a network 169 | request to the service handler. 170 | 171 | Custom request headers may be provided via `GetOperationInfoOptions`. 172 | 173 | ```go 174 | info, _ := handle.GetInfo(ctx, nexus.GetOperationInfoOptions{}) 175 | ``` 176 | 177 | #### Cancel an Operation 178 | 179 | The `Cancel` method requests cancelation of an asynchronous operation. 180 | 181 | Cancelation in Nexus is asynchronous and may be not be respected by the operation's implementation. 182 | 183 | Custom request headers may be provided via `CancelOperationOptions`. 184 | 185 | ```go 186 | _ := handle.Cancel(ctx, nexus.CancelOperationOptions{}) 187 | ``` 188 | 189 | #### Complete an Operation 190 | 191 | Handlers starting asynchronous operations may need to deliver responses via a caller specified callback URL. 192 | 193 | `NewCompletionHTTPRequest` is used to construct an HTTP request to deliver operation completions - successful or 194 | unsuccessful - to the provided callback URL. 195 | 196 | To deliver successful completions, pass a `OperationCompletionSuccessful` struct pointer, which may also be constructed 197 | with the `NewOperationCompletionSuccessful` helper. 198 | 199 | Custom HTTP headers may be provided via `OperationCompletionSuccessful.Header`. 200 | 201 | ```go 202 | completion, _ := nexus.NewOperationCompletionSuccessful(MyStruct{Field: "value"}, OperationCompletionSuccessfulOptions{}) 203 | request, _ := nexus.NewCompletionHTTPRequest(ctx, callbackURL, completion) 204 | response, _ := http.DefaultClient.Do(request) 205 | defer response.Body.Close() 206 | _, err = io.ReadAll(response.Body) 207 | fmt.Println("delivered completion with status code", response.StatusCode) 208 | ``` 209 | 210 | To deliver failed and canceled completions, pass a `OperationCompletionUnsuccessful` struct pointer constructed with 211 | `NewOperationCompletionUnsuccessful`. 212 | 213 | Custom HTTP headers may be provided via `OperationCompletionUnsuccessful.Header`. 214 | 215 | ```go 216 | completion := nexus.NewOperationCompletionUnsuccessful(nexus.NewOperationFailedError("some error"), nexus.OperationCompletionUnsuccessfulOptions{}) 217 | request, _ := nexus.NewCompletionHTTPRequest(ctx, callbackURL, completion) 218 | // ... 219 | ``` 220 | 221 | ### Server 222 | 223 | To handle operation requests, implement the `Operation` interface and use the `OperationRegistry` to create a `Handler` 224 | that can be used to serve requests over HTTP. 225 | 226 | Implement `CompletionHandler` to handle async delivery of operation completions. 227 | 228 | #### Implement a Sync Operation 229 | 230 | ```go 231 | var exampleOperation = NewSyncOperation("example", func(ctx context.Context, input MyInput, options StartOperationOptions) (MyOutput, error) { 232 | return MyOutput{Field: "value"}, nil 233 | }) 234 | ``` 235 | 236 | #### Implement an Arbitrary Length Operation 237 | 238 | ```go 239 | type myArbitraryLengthOperation struct { 240 | nexus.UnimplementedOperation[MyInput, MyOutput] 241 | } 242 | 243 | func (h *myArbitraryLengthOperation) Name() string { 244 | return "alo-example" 245 | } 246 | 247 | func (h *myArbitraryLengthOperation) Start(ctx context.Context, input MyInput, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[MyOutput], error) { 248 | // alternatively return &HandlerStartOperationResultSync{Value: MyOutput{}}, nil 249 | return &HandlerStartOperationResultAsync{OperationToken: "BASE64_ENCODED_DATA"}, nil 250 | } 251 | 252 | func (h *myArbitraryLengthOperation) GetResult(ctx context.Context, token string, options nexus.GetOperationResultOptions) (MyOutput, error) { 253 | return MyOutput{}, nil 254 | } 255 | 256 | func (h *myArbitraryLengthOperation) Cancel(ctx context.Context, token string, options nexus.CancelOperationOptions) error { 257 | fmt.Println("Canceling", h.Name(), "with token:", token) 258 | return nil 259 | } 260 | 261 | func (h *myArbitraryLengthOperation) GetInfo(ctx context.Context, token string, options nexus.GetOperationInfoOptions) (*nexus.OperationInfo, error) { 262 | return &nexus.OperationInfo{Token: token, State: nexus.OperationStateRunning}, nil 263 | } 264 | ``` 265 | 266 | #### Create an HTTP Handler 267 | 268 | ```go 269 | svc := NewService("example-service") 270 | _ = svc.Register(exampleOperation, &myArbitraryLengthOperation{}) 271 | reg := NewServiceRegistry() 272 | _ = reg.Register(svc) 273 | handler, _ = reg.NewHandler() 274 | 275 | httpHandler := nexus.NewHTTPHandler(nexus.HandlerOptions{ 276 | Handler: handler, 277 | }) 278 | 279 | listener, _ := net.Listen("tcp", "localhost:0") 280 | // Handler URLs can be prefixed by using a request multiplexer (e.g. https://pkg.go.dev/net/http#ServeMux). 281 | _ = http.Serve(listener, httpHandler) 282 | ``` 283 | 284 | #### Respond Synchronously with Failure 285 | 286 | ```go 287 | func (h *myArbitraryLengthOperation) Start(ctx context.Context, input MyInput, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[MyOutput], error) { 288 | // Alternatively use NewCanceledOperationError to resolve an operation as canceled. 289 | return nil, nexus.NewOperationFailedError("Do or do not, there is not try") 290 | } 291 | ``` 292 | 293 | #### Get Operation Result 294 | 295 | The `GetResult` method is used to deliver an operation's result inline. If this method does not return an error, the 296 | operation is considered as successfully completed. Return an `OperationError` to indicate completion or an 297 | `ErrOperationStillRunning` error to indicate that the operation is still running. 298 | 299 | When `GetOperationResultOptions.Wait` is greater than zero, this request should be treated as a long poll. Long poll 300 | requests have a server side timeout, configurable via `HandlerOptions.GetResultTimeout`, and exposed via context 301 | deadline. The context deadline is decoupled from the application level Wait duration. 302 | 303 | It is the implementor's responsiblity to respect the client's wait duration and return in a timely fashion. 304 | Consider using a derived context that enforces the wait timeout when implementing this method and return 305 | `ErrOperationStillRunning` when that context expires as shown in the example. 306 | 307 | ```go 308 | func (h *myArbitraryLengthOperation) GetResult(ctx context.Context, token string, options nexus.GetOperationResultOptions) (MyOutput, error) { 309 | if options.Wait > 0 { // request is a long poll 310 | var cancel context.CancelFunc 311 | ctx, cancel = context.WithTimeout(ctx, options.Wait) 312 | defer cancel() 313 | 314 | result, err := h.pollOperation(ctx, options.Wait) 315 | if err != nil { 316 | // Translate deadline exceeded to "OperationStillRunning", this may or may not be semantically correct for 317 | // your application. 318 | // Some applications may want to "peek" the current status instead of performing this blind conversion if 319 | // the wait time is exceeded and the request's context deadline has not yet exceeded. 320 | if ctx.Err() != nil { 321 | return nil, nexus.ErrOperationStillRunning 322 | } 323 | // Optionally translate to operation failure (could also result in canceled state). 324 | // Optionally expose the error details to the caller. 325 | return nil, &nexus.OperationError{ 326 | State: nexus.OperationStateFailed, 327 | Cause: err, 328 | } 329 | } 330 | return result, nil 331 | } else { 332 | result, err := h.peekOperation(ctx) 333 | if err != nil { 334 | // Optionally translate to operation failure (could also result in canceled state). 335 | return nil, &nexus.OperationError{ 336 | State: nexus.OperationStateFailed, 337 | Cause: err, 338 | } 339 | } 340 | return result, nil 341 | } 342 | } 343 | ``` 344 | 345 | #### Handle Asynchronous Completion 346 | 347 | Implement `CompletionHandler.CompleteOperation` to get async operation completions. 348 | 349 | ```go 350 | type myCompletionHandler struct {} 351 | 352 | httpHandler := nexus.NewCompletionHTTPHandler(nexus.CompletionHandlerOptions{ 353 | Handler: &myCompletionHandler{}, 354 | }) 355 | 356 | func (h *myCompletionHandler) CompleteOperation(ctx context.Context, completion *nexus.CompletionRequest) error { 357 | switch completion.State { 358 | case nexus.OperationStateCanceled, case nexus.OperationStateFailed: 359 | // completion.Failure will be popluated here 360 | case nexus.OperationStateSucceeded: 361 | // read completion.HTTPRequest Header and Body 362 | } 363 | return nil 364 | } 365 | ``` 366 | 367 | #### Fail a Request 368 | 369 | Returning an arbitrary error from any of the `Operation` and `CompletionHandler` methods will result in the error being 370 | logged and the request responded to with a generic Internal Server Error status code and Failure message. 371 | 372 | To fail a request with a custom status code and failure message, return a `nexus.HandlerError` as the error. 373 | The error can either be constructed directly or with the `HandlerErrorf` helper. 374 | 375 | ```go 376 | func (h *myArbitraryLengthOperation) Start(ctx context.Context, input MyInput, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[MyOutput], error) { 377 | return nil, nexus.HandlerErrorf(nexus.HandlerErrorTypeBadRequest, "unmet expectation") 378 | } 379 | ``` 380 | 381 | ### Logging 382 | 383 | The handlers log internally and accept a `log/slog.Logger` to customize their log output, defaults to `slog.Default()`. 384 | 385 | ## Failure Structs 386 | 387 | `nexus` exports a `Failure` struct that is used in both the client and handlers to represent both application level 388 | operation failures and framework level HTTP request errors. 389 | 390 | `Failure`s typically contain a single `Message` string but may also convey arbitrary JSONable `Details` and `Metadata`. 391 | 392 | The `Details` field is encoded and it is up to the library user to encode to and decode from it. 393 | 394 | A failure can be either directly attached to `HandlerError` and `OperationError` instances by providing `FailureError` 395 | as the `Cause`, or indirectly by implementing the `FailureConverter` interface, which can translate arbitrary user 396 | defined errors to `Failure` instances and back. 397 | 398 | ### Links 399 | 400 | Nexus operations can bi-directionally link the caller and handler for tracing the execution path. A caller may provide 401 | a set of `Link` objects via `StartOperationOptions` that the handler may log or attach to any underlying resources 402 | backing the operation. A handler may attach backlinks when responding to a `StartOperation` request via the a 403 | `AddHandlerLinks` method. 404 | 405 | #### Handler 406 | 407 | ```go 408 | func (h *myArbitraryLengthOperation) Start(ctx context.Context, input MyInput, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[MyOutput], error) { 409 | output, backlinks, _ := createMyBackingResourceAndAttachCallerLinks(ctx, input, options.Links) 410 | nexus.AddHandlerLinks(ctx, backlinks) 411 | return output, nil 412 | } 413 | 414 | result, _ := nexus.StartOperation(ctx, client, operation, MyInput{Field: "value"}, nexus.StartOperationOptions{ 415 | Links: []nexus.Link{ 416 | { 417 | Type: "org.my.MyResource", 418 | URL: &url.URL{/* ... */}, 419 | }, 420 | }, 421 | }) 422 | fmt.Println("got result with backlinks", result.Links) 423 | ``` 424 | 425 | ### Middleware 426 | 427 | The ServiceRegistry supports middleware registration via the `Use` method. The registry's handler will invoke every 428 | registered middleware in registration order. Typical use cases for middleware include global enforcement of 429 | authorization and logging. 430 | 431 | Middleware is implemented as a function that takes the current context and the next handler in the invocation chain and 432 | returns a new handler to invoke. The function can pass through the given handler or return an error to abort the 433 | execution. The registered middleware function has access to common handler information such as the current service, 434 | operation, and request headers. To get access to more specific handler method information, such as inputs and operation 435 | tokens, wrap the given handler. 436 | 437 | **Example** 438 | 439 | ```go 440 | type loggingOperation struct { 441 | nexus.UnimplementedOperation[any, any] // All OperationHandlers must embed this. 442 | next nexus.OperationHandler[any, any] 443 | } 444 | 445 | func (lo *loggingOperation) Start(ctx context.Context, input any, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[any], error) { 446 | log.Println("starting operation", ExtractHandlerInfo(ctx).Operation) 447 | return lo.next.Start(ctx, input, options) 448 | } 449 | 450 | func (lo *loggingOperation) GetResult(ctx context.Context, token string, options nexus.GetOperationResultOptions) (any, error) { 451 | log.Println("getting result for operation", ExtractHandlerInfo(ctx).Operation) 452 | return lo.next.GetResult(ctx, token, options) 453 | } 454 | 455 | func (lo *loggingOperation) Cancel(ctx context.Context, token string, options nexus.CancelOperationOptions) error { 456 | log.Printf("canceling operation", ExtractHandlerInfo(ctx).Operation) 457 | return lo.next.Cancel(ctx, token, options) 458 | } 459 | 460 | func (lo *loggingOperation) GetInfo(ctx context.Context, token string, options nexus.GetOperationInfoOptions) (*nexus.OperationInfo, error) { 461 | log.Println("getting info for operation", ExtractHandlerInfo(ctx).Operation) 462 | return lo.next.GetInfo(ctx, token, options) 463 | } 464 | 465 | registry.Use(func(ctx context.Context, next nexus.OperationHandler[any, any]) (nexus.OperationHandler[any, any], error) { 466 | // Optionally call ExtractHandlerInfo(ctx) here. 467 | return &loggingOperation{next: next}, nil 468 | }) 469 | ``` 470 | 471 | ## Contributing 472 | 473 | ### Prerequisites 474 | 475 | - [Go 1.21](https://go.dev/doc/install) 476 | - [golangci-lint](https://golangci-lint.run/usage/install/) 477 | 478 | ### Test 479 | 480 | ```shell 481 | go test -v ./... 482 | ``` 483 | 484 | ### Lint 485 | 486 | ```shell 487 | golangci-lint run --verbose --timeout 1m --fix=false 488 | ``` 489 | -------------------------------------------------------------------------------- /contrib/nexusproto/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nexus-rpc/sdk-go/contrib/nexusproto 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/nexus-rpc/sdk-go v0.3.0 7 | github.com/stretchr/testify v1.8.4 8 | google.golang.org/protobuf v1.36.6 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/google/uuid v1.3.0 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | 18 | replace github.com/nexus-rpc/sdk-go => ../.. 19 | -------------------------------------------------------------------------------- /contrib/nexusproto/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 4 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 5 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 6 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 10 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 11 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 12 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 13 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 14 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /contrib/nexusproto/serializer.go: -------------------------------------------------------------------------------- 1 | package nexusproto 2 | 3 | import ( 4 | "fmt" 5 | "mime" 6 | "reflect" 7 | 8 | "github.com/nexus-rpc/sdk-go/nexus" 9 | "google.golang.org/protobuf/encoding/protojson" 10 | "google.golang.org/protobuf/proto" 11 | ) 12 | 13 | func messageFromAny(v any) (proto.Message, error) { 14 | rv := reflect.ValueOf(v) 15 | if rv.Kind() != reflect.Ptr { 16 | return nil, fmt.Errorf("invalid protobuf object: type: %T", v) 17 | } 18 | elem := rv.Type().Elem() 19 | 20 | // v is a proto.Message or *proto.Message, the serializer supports either: 21 | // 1. var d durationpb.Duration; s.Deserialize(&d) 22 | // 2. var d *durationpb.Duration; s.Deserialize(&d) 23 | // In the second case, we need to instantiate an empty proto struct: 24 | if elem.Kind() == reflect.Ptr { 25 | empty := reflect.New(elem.Elem()) 26 | rv.Elem().Set(empty) 27 | rv = empty 28 | } 29 | 30 | msg, ok := rv.Interface().(proto.Message) 31 | if !ok { 32 | return nil, fmt.Errorf("%w: value is not a proto.Message or a pointer to one", nexus.ErrSerializerIncompatible) 33 | } 34 | 35 | return msg, nil 36 | } 37 | 38 | type protoJsonSerializer struct{} 39 | 40 | func (protoJsonSerializer) extractMessageType(contentType string) string { 41 | if contentType == "" { 42 | return "" 43 | } 44 | mediaType, params, err := mime.ParseMediaType(contentType) 45 | if err != nil || mediaType != "application/json" || len(params) != 2 || params["format"] != "protobuf" { 46 | return "" 47 | } 48 | return params["message-type"] 49 | } 50 | 51 | func (s protoJsonSerializer) Deserialize(c *nexus.Content, v any) error { 52 | messageTypeFromHeader := s.extractMessageType(c.Header["type"]) 53 | if messageTypeFromHeader == "" { 54 | return fmt.Errorf("%w: incompatible content type header", nexus.ErrSerializerIncompatible) 55 | } 56 | msg, err := messageFromAny(v) 57 | if err != nil { 58 | return err 59 | } 60 | messageTypeFromValue := string(msg.ProtoReflect().Type().Descriptor().FullName()) 61 | if messageTypeFromHeader != messageTypeFromValue { 62 | return fmt.Errorf("serialized message type: %q is different from the provided value: %q", messageTypeFromHeader, messageTypeFromValue) 63 | 64 | } 65 | return protojson.Unmarshal(c.Data, msg) 66 | } 67 | 68 | func (protoJsonSerializer) Serialize(v any) (*nexus.Content, error) { 69 | msg, ok := v.(proto.Message) 70 | if !ok { 71 | return nil, fmt.Errorf("%w: value is not a proto.Message", nexus.ErrSerializerIncompatible) 72 | } 73 | data, err := protojson.Marshal(msg) 74 | if err != nil { 75 | return nil, err 76 | } 77 | messageType := string(msg.ProtoReflect().Descriptor().FullName()) 78 | return &nexus.Content{ 79 | Header: nexus.Header{ 80 | "type": mime.FormatMediaType("application/json", map[string]string{ 81 | "format": "protobuf", 82 | "message-type": messageType, 83 | }), 84 | }, 85 | Data: data, 86 | }, nil 87 | } 88 | 89 | type protoBinarySerializer struct{} 90 | 91 | func (protoBinarySerializer) extractMessageType(contentType string) string { 92 | if contentType == "" { 93 | return "" 94 | } 95 | mediaType, params, err := mime.ParseMediaType(contentType) 96 | if err != nil || mediaType != "application/x-protobuf" || len(params) != 1 { 97 | return "" 98 | } 99 | return params["message-type"] 100 | } 101 | 102 | func (s protoBinarySerializer) Deserialize(c *nexus.Content, v any) error { 103 | messageTypeFromHeader := s.extractMessageType(c.Header["type"]) 104 | if messageTypeFromHeader == "" { 105 | return fmt.Errorf("%w: incompatible content type header", nexus.ErrSerializerIncompatible) 106 | } 107 | msg, err := messageFromAny(v) 108 | if err != nil { 109 | return err 110 | } 111 | messageTypeFromValue := string(msg.ProtoReflect().Type().Descriptor().FullName()) 112 | if messageTypeFromHeader != messageTypeFromValue { 113 | return fmt.Errorf("serialized message type: %q is different from the provided value: %q", messageTypeFromHeader, messageTypeFromValue) 114 | 115 | } 116 | return proto.Unmarshal(c.Data, msg) 117 | } 118 | 119 | func (protoBinarySerializer) Serialize(v any) (*nexus.Content, error) { 120 | msg, ok := v.(proto.Message) 121 | if !ok { 122 | return nil, fmt.Errorf("%w: value is not a proto.Message", nexus.ErrSerializerIncompatible) 123 | } 124 | data, err := proto.Marshal(msg) 125 | if err != nil { 126 | return nil, err 127 | } 128 | messageType := string(msg.ProtoReflect().Descriptor().FullName()) 129 | return &nexus.Content{ 130 | Header: nexus.Header{ 131 | "type": mime.FormatMediaType("application/x-protobuf", map[string]string{ 132 | "message-type": messageType, 133 | }), 134 | }, 135 | Data: data, 136 | }, nil 137 | } 138 | 139 | // SerializerMode controls the preferred serialization format. 140 | type SerializerMode int 141 | 142 | const ( 143 | // SerializerModePreferJSON instructs the serializer to prefer to serialize in proto JSON format. 144 | SerializerModePreferJSON = SerializerMode(iota) 145 | // SerializerModePreferBinary instructs the serializer to prefer to serialize in proto binary format. 146 | SerializerModePreferBinary 147 | ) 148 | 149 | type SerializerOptions struct { 150 | // Mode is the preferred mode for the serializer. 151 | // The serializer supports deserializing both JSON and binary formats, but will prefer to serialize in the given 152 | // format. 153 | Mode SerializerMode 154 | } 155 | 156 | // NewSerializer constructs a Protobuf [nexus.Serializer] with the given options. 157 | // The returned serializer supports serializing nil and proto messages. 158 | func NewSerializer(options SerializerOptions) nexus.Serializer { 159 | serializers := []nexus.Serializer{ 160 | nexus.NilSerializer{}, 161 | } 162 | if options.Mode == SerializerModePreferJSON { 163 | serializers = append(serializers, 164 | protoJsonSerializer{}, 165 | protoBinarySerializer{}, 166 | ) 167 | } else { 168 | serializers = append(serializers, 169 | protoBinarySerializer{}, 170 | protoJsonSerializer{}, 171 | ) 172 | } 173 | return nexus.CompositeSerializer(serializers) 174 | } 175 | -------------------------------------------------------------------------------- /contrib/nexusproto/serializer_test.go: -------------------------------------------------------------------------------- 1 | package nexusproto_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/nexus-rpc/sdk-go/contrib/nexusproto" 8 | "github.com/nexus-rpc/sdk-go/nexus" 9 | "github.com/stretchr/testify/require" 10 | "google.golang.org/protobuf/types/known/durationpb" 11 | ) 12 | 13 | func TestSerializer(t *testing.T) { 14 | binSerializer := nexusproto.NewSerializer(nexusproto.SerializerOptions{Mode: nexusproto.SerializerModePreferBinary}) 15 | jsonSerializer := nexusproto.NewSerializer(nexusproto.SerializerOptions{Mode: nexusproto.SerializerModePreferJSON}) 16 | d := durationpb.New(time.Minute) 17 | 18 | cases := []struct { 19 | name string 20 | serializer nexus.Serializer 21 | deserializer nexus.Serializer 22 | }{ 23 | { 24 | name: "JSON2JSON", 25 | serializer: jsonSerializer, 26 | deserializer: jsonSerializer, 27 | }, 28 | { 29 | name: "JSON2Binary", 30 | serializer: jsonSerializer, 31 | deserializer: binSerializer, 32 | }, 33 | { 34 | name: "Binary2JSON", 35 | serializer: binSerializer, 36 | deserializer: jsonSerializer, 37 | }, 38 | { 39 | name: "Binary2Binary", 40 | serializer: binSerializer, 41 | deserializer: binSerializer, 42 | }, 43 | } 44 | for _, tc := range cases { 45 | t.Run(tc.name, func(t *testing.T) { 46 | b, err := tc.serializer.Serialize(d) 47 | require.NoError(t, err) 48 | var singlePtr durationpb.Duration 49 | err = tc.deserializer.Deserialize(b, &singlePtr) 50 | require.NoError(t, err) 51 | require.Equal(t, time.Minute, singlePtr.AsDuration()) 52 | var doublePtr *durationpb.Duration 53 | err = tc.deserializer.Deserialize(b, &doublePtr) 54 | require.NoError(t, err) 55 | require.Equal(t, time.Minute, doublePtr.AsDuration()) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nexus-rpc/sdk-go 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/google/uuid v1.3.0 7 | github.com/stretchr/testify v1.8.4 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 4 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 8 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /nexus/api.go: -------------------------------------------------------------------------------- 1 | // Package nexus provides client and server implementations of the Nexus [HTTP API] 2 | // 3 | // [HTTP API]: https://github.com/nexus-rpc/api 4 | package nexus 5 | 6 | import ( 7 | "context" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "mime" 12 | "net/http" 13 | "net/url" 14 | "regexp" 15 | "strconv" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | // Package version. 21 | const version = "v0.4.0" 22 | 23 | const ( 24 | // Nexus specific headers. 25 | headerOperationState = "nexus-operation-state" 26 | headerRequestID = "nexus-request-id" 27 | headerLink = "nexus-link" 28 | headerOperationStartTime = "nexus-operation-start-time" 29 | headerRetryable = "nexus-request-retryable" 30 | // HeaderOperationID is the unique ID returned by the StartOperation response for async operations. 31 | // Must be set on callback headers to support completing operations before the start response is received. 32 | // 33 | // Deprecated: Use HeaderOperationToken instead. 34 | HeaderOperationID = "nexus-operation-id" 35 | 36 | // HeaderOperationToken is the unique token returned by the StartOperation response for async operations. 37 | // Must be set on callback headers to support completing operations before the start response is received. 38 | HeaderOperationToken = "nexus-operation-token" 39 | 40 | // HeaderRequestTimeout is the total time to complete a Nexus HTTP request. 41 | HeaderRequestTimeout = "request-timeout" 42 | // HeaderOperationTimeout is the total time to complete a Nexus operation. 43 | // Unlike HeaderRequestTimeout, this applies to the whole operation, not just a single HTTP request. 44 | HeaderOperationTimeout = "operation-timeout" 45 | ) 46 | 47 | const contentTypeJSON = "application/json" 48 | 49 | // Query param for passing a callback URL. 50 | const ( 51 | queryCallbackURL = "callback" 52 | // Query param for passing wait duration. 53 | queryWait = "wait" 54 | ) 55 | 56 | const ( 57 | statusOperationRunning = http.StatusPreconditionFailed 58 | // HTTP status code for failed operation responses. 59 | statusOperationFailed = http.StatusFailedDependency 60 | StatusUpstreamTimeout = 520 61 | ) 62 | 63 | // A Failure represents failed handler invocations as well as `failed` or `canceled` operation results. Failures 64 | // shouldn't typically be constructed directly. The SDK APIs take a [FailureConverter] instance that can translate 65 | // language errors to and from [Failure] instances. 66 | type Failure struct { 67 | // A simple text message. 68 | Message string `json:"message"` 69 | // A key-value mapping for additional context. Useful for decoding the 'details' field, if needed. 70 | Metadata map[string]string `json:"metadata,omitempty"` 71 | // Additional JSON serializable structured data. 72 | Details json.RawMessage `json:"details,omitempty"` 73 | } 74 | 75 | // An error that directly represents a wire representation of [Failure]. 76 | // The SDK will convert to this error by default unless the [FailureConverter] instance is customized. 77 | type FailureError struct { 78 | // The underlying Failure object this error represents. 79 | Failure Failure 80 | } 81 | 82 | // Error implements the error interface. 83 | func (e *FailureError) Error() string { 84 | return e.Failure.Message 85 | } 86 | 87 | // OperationError represents "failed" and "canceled" operation results. 88 | type OperationError struct { 89 | // State of the operation. Only [OperationStateFailed] and [OperationStateCanceled] are valid. 90 | State OperationState 91 | // The underlying cause for this error. 92 | Cause error 93 | } 94 | 95 | // UnsuccessfulOperationError represents "failed" and "canceled" operation results. 96 | // 97 | // Deprecated: Use [OperationError] instead. 98 | type UnsuccessfulOperationError = OperationError 99 | 100 | // NewFailedOperationError is shorthand for constructing an [OperationError] with state set to 101 | // [OperationStateFailed] and the given err as the cause. 102 | // 103 | // Deprecated: Use [NewOperationFailedError] or construct an [OperationError] directly instead. 104 | func NewFailedOperationError(err error) *OperationError { 105 | return &OperationError{ 106 | State: OperationStateFailed, 107 | Cause: err, 108 | } 109 | } 110 | 111 | // NewOperationFailedError is shorthand for constructing an [OperationError] with state set to 112 | // [OperationStateFailed] and the given error message as the cause. 113 | func NewOperationFailedError(message string) *OperationError { 114 | return &OperationError{ 115 | State: OperationStateFailed, 116 | Cause: errors.New(message), 117 | } 118 | } 119 | 120 | // OperationFailedErrorf creates an [OperationError] with state set to [OperationStateFailed], using [fmt.Errorf] to 121 | // construct the cause. 122 | func OperationFailedErrorf(format string, args ...any) *OperationError { 123 | return &OperationError{ 124 | State: OperationStateFailed, 125 | Cause: fmt.Errorf(format, args...), 126 | } 127 | } 128 | 129 | // NewCanceledOperationError is shorthand for constructing an [OperationError] with state set to 130 | // [OperationStateCanceled] and the given err as the cause. 131 | // 132 | // Deprecated: Use [NewOperationCanceledError] or construct an [OperationError] directly instead. 133 | func NewCanceledOperationError(err error) *OperationError { 134 | return &OperationError{ 135 | State: OperationStateCanceled, 136 | Cause: err, 137 | } 138 | } 139 | 140 | // NewOperationCanceledError is shorthand for constructing an [OperationError] with state set to 141 | // [OperationStateCanceled] and the given error message as the cause. 142 | func NewOperationCanceledError(message string) *OperationError { 143 | return &OperationError{ 144 | State: OperationStateCanceled, 145 | Cause: errors.New(message), 146 | } 147 | } 148 | 149 | // OperationCanceledErrorf creates an [OperationError] with state set to [OperationStateCanceled], using [fmt.Errorf] to 150 | // construct the cause. 151 | func OperationCanceledErrorf(format string, args ...any) *OperationError { 152 | return &OperationError{ 153 | State: OperationStateCanceled, 154 | Cause: fmt.Errorf(format, args...), 155 | } 156 | } 157 | 158 | // OperationErrorf creates an [OperationError] with the given state, using [fmt.Errorf] to construct the cause. 159 | func OperationErrorf(state OperationState, format string, args ...any) *OperationError { 160 | return &OperationError{ 161 | State: state, 162 | Cause: fmt.Errorf(format, args...), 163 | } 164 | } 165 | 166 | // Error implements the error interface. 167 | func (e *OperationError) Error() string { 168 | if e.Cause == nil { 169 | return fmt.Sprintf("operation %s", e.State) 170 | } 171 | return fmt.Sprintf("operation %s: %s", e.State, e.Cause.Error()) 172 | } 173 | 174 | // Unwrap returns the cause for use with utilities in the errors package. 175 | func (e *OperationError) Unwrap() error { 176 | return e.Cause 177 | } 178 | 179 | // HandlerErrorType is an error type associated with a [HandlerError], defined according to the Nexus specification. 180 | // Only the types defined as consts in this package are valid. Do not use other values. 181 | type HandlerErrorType string 182 | 183 | const ( 184 | // The server cannot or will not process the request due to an apparent client error. Clients should not retry 185 | // this request unless advised otherwise. 186 | HandlerErrorTypeBadRequest HandlerErrorType = "BAD_REQUEST" 187 | // The client did not supply valid authentication credentials for this request. Clients should not retry 188 | // this request unless advised otherwise. 189 | HandlerErrorTypeUnauthenticated HandlerErrorType = "UNAUTHENTICATED" 190 | // The caller does not have permission to execute the specified operation. Clients should not retry this 191 | // request unless advised otherwise. 192 | HandlerErrorTypeUnauthorized HandlerErrorType = "UNAUTHORIZED" 193 | // The requested resource could not be found but may be available in the future. Clients should not retry this 194 | // request unless advised otherwise. 195 | HandlerErrorTypeNotFound HandlerErrorType = "NOT_FOUND" 196 | // Some resource has been exhausted, perhaps a per-user quota, or perhaps the entire file system is out of 197 | // space. Subsequent requests by the client are permissible. 198 | HandlerErrorTypeResourceExhausted HandlerErrorType = "RESOURCE_EXHAUSTED" 199 | // An internal error occured. Subsequent requests by the client are permissible. 200 | HandlerErrorTypeInternal HandlerErrorType = "INTERNAL" 201 | // The server either does not recognize the request method, or it lacks the ability to fulfill the request. 202 | // Clients should not retry this request unless advised otherwise. 203 | HandlerErrorTypeNotImplemented HandlerErrorType = "NOT_IMPLEMENTED" 204 | // The service is currently unavailable. Subsequent requests by the client are permissible. 205 | HandlerErrorTypeUnavailable HandlerErrorType = "UNAVAILABLE" 206 | // Used by gateways to report that a request to an upstream server has timed out. Subsequent requests by the 207 | // client are permissible. 208 | HandlerErrorTypeUpstreamTimeout HandlerErrorType = "UPSTREAM_TIMEOUT" 209 | ) 210 | 211 | // HandlerErrorRetryBehavior allows handlers to explicity set the retry behavior of a [HandlerError]. If not specified, 212 | // retry behavior is determined from the error type. For example [HandlerErrorTypeInternal] is not retryable by default 213 | // unless specified otherwise. 214 | type HandlerErrorRetryBehavior int 215 | 216 | const ( 217 | // HandlerErrorRetryBehaviorUnspecified indicates that the retry behavior for a [HandlerError] is determined 218 | // from the [HandlerErrorType]. 219 | HandlerErrorRetryBehaviorUnspecified HandlerErrorRetryBehavior = iota 220 | // HandlerErrorRetryBehaviorRetryable explicitly indicates that a [HandlerError] should be retried, overriding 221 | // the default retry behavior of the [HandlerErrorType]. 222 | HandlerErrorRetryBehaviorRetryable 223 | // HandlerErrorRetryBehaviorNonRetryable explicitly indicates that a [HandlerError] should not be retried, 224 | // overriding the default retry behavior of the [HandlerErrorType]. 225 | HandlerErrorRetryBehaviorNonRetryable 226 | ) 227 | 228 | // HandlerError is a special error that can be returned from [Handler] methods for failing a request with a custom 229 | // status code and failure message. 230 | type HandlerError struct { 231 | // Error Type. Defaults to HandlerErrorTypeInternal. 232 | Type HandlerErrorType 233 | // The underlying cause for this error. 234 | Cause error 235 | // RetryBehavior of this error. If not specified, retry behavior is determined from the error type. 236 | RetryBehavior HandlerErrorRetryBehavior 237 | } 238 | 239 | // HandlerErrorf creates a [HandlerError] with the given type, using [fmt.Errorf] to construct the cause. 240 | func HandlerErrorf(typ HandlerErrorType, format string, args ...any) *HandlerError { 241 | return &HandlerError{ 242 | Type: typ, 243 | Cause: fmt.Errorf(format, args...), 244 | } 245 | } 246 | 247 | // Retryable returns a boolean indicating whether or not this error is retryable based on the error's RetryBehavior and 248 | // Type. 249 | func (e *HandlerError) Retryable() bool { 250 | switch e.RetryBehavior { 251 | case HandlerErrorRetryBehaviorNonRetryable: 252 | return false 253 | case HandlerErrorRetryBehaviorRetryable: 254 | return true 255 | } 256 | switch e.Type { 257 | case HandlerErrorTypeBadRequest, 258 | HandlerErrorTypeUnauthenticated, 259 | HandlerErrorTypeUnauthorized, 260 | HandlerErrorTypeNotFound, 261 | HandlerErrorTypeNotImplemented: 262 | return false 263 | case HandlerErrorTypeResourceExhausted, 264 | HandlerErrorTypeInternal, 265 | HandlerErrorTypeUnavailable, 266 | HandlerErrorTypeUpstreamTimeout: 267 | return true 268 | default: 269 | return true 270 | } 271 | } 272 | 273 | // Error implements the error interface. 274 | func (e *HandlerError) Error() string { 275 | typ := e.Type 276 | if len(typ) == 0 { 277 | typ = HandlerErrorTypeInternal 278 | } 279 | if e.Cause == nil { 280 | return fmt.Sprintf("handler error (%s)", typ) 281 | } 282 | return fmt.Sprintf("handler error (%s): %s", typ, e.Cause.Error()) 283 | } 284 | 285 | // Unwrap returns the cause for use with utilities in the errors package. 286 | func (e *HandlerError) Unwrap() error { 287 | return e.Cause 288 | } 289 | 290 | // ErrOperationStillRunning indicates that an operation is still running while trying to get its result. 291 | // 292 | // NOTE: Experimental 293 | var ErrOperationStillRunning = errors.New("operation still running") 294 | 295 | // OperationInfo conveys information about an operation. 296 | type OperationInfo struct { 297 | // ID of the operation. 298 | // 299 | // Deprecated: Use Token instead. 300 | ID string `json:"id"` 301 | // Token for the operation. 302 | Token string `json:"token"` 303 | // State of the operation. 304 | State OperationState `json:"state"` 305 | } 306 | 307 | // OperationState represents the variable states of an operation. 308 | type OperationState string 309 | 310 | const ( 311 | // "running" operation state. Indicates an operation is started and not yet completed. 312 | OperationStateRunning OperationState = "running" 313 | // "succeeded" operation state. Indicates an operation completed successfully. 314 | OperationStateSucceeded OperationState = "succeeded" 315 | // "failed" operation state. Indicates an operation completed as failed. 316 | OperationStateFailed OperationState = "failed" 317 | // "canceled" operation state. Indicates an operation completed as canceled. 318 | OperationStateCanceled OperationState = "canceled" 319 | ) 320 | 321 | // isMediaTypeJSON returns true if the given content type's media type is application/json. 322 | func isMediaTypeJSON(contentType string) bool { 323 | if contentType == "" { 324 | return false 325 | } 326 | mediaType, _, err := mime.ParseMediaType(contentType) 327 | return err == nil && mediaType == "application/json" 328 | } 329 | 330 | // isMediaTypeOctetStream returns true if the given content type's media type is application/octet-stream. 331 | func isMediaTypeOctetStream(contentType string) bool { 332 | if contentType == "" { 333 | return false 334 | } 335 | mediaType, _, err := mime.ParseMediaType(contentType) 336 | return err == nil && mediaType == "application/octet-stream" 337 | } 338 | 339 | // Header is a mapping of string to string. 340 | // It is used throughout the framework to transmit metadata. 341 | // The keys should be in lower case form. 342 | type Header map[string]string 343 | 344 | // Get is a case-insensitive key lookup from the header map. 345 | func (h Header) Get(k string) string { 346 | return h[strings.ToLower(k)] 347 | } 348 | 349 | // Set sets the header key to the given value transforming the key to its lower case form. 350 | func (h Header) Set(k, v string) { 351 | h[strings.ToLower(k)] = v 352 | } 353 | 354 | func prefixStrippedHTTPHeaderToNexusHeader(httpHeader http.Header, prefix string) Header { 355 | header := Header{} 356 | for k, v := range httpHeader { 357 | lowerK := strings.ToLower(k) 358 | if strings.HasPrefix(lowerK, prefix) { 359 | // Nexus headers can only have single values, ignore multiple values. 360 | header[lowerK[len(prefix):]] = v[0] 361 | } 362 | } 363 | return header 364 | } 365 | 366 | func addContentHeaderToHTTPHeader(nexusHeader Header, httpHeader http.Header) http.Header { 367 | for k, v := range nexusHeader { 368 | httpHeader.Set("Content-"+k, v) 369 | } 370 | return httpHeader 371 | } 372 | 373 | func addCallbackHeaderToHTTPHeader(nexusHeader Header, httpHeader http.Header) http.Header { 374 | for k, v := range nexusHeader { 375 | httpHeader.Set("Nexus-Callback-"+k, v) 376 | } 377 | return httpHeader 378 | } 379 | 380 | func addLinksToHTTPHeader(links []Link, httpHeader http.Header) error { 381 | for _, link := range links { 382 | encodedLink, err := encodeLink(link) 383 | if err != nil { 384 | return err 385 | } 386 | httpHeader.Add(headerLink, encodedLink) 387 | } 388 | return nil 389 | } 390 | 391 | func getLinksFromHeader(httpHeader http.Header) ([]Link, error) { 392 | var links []Link 393 | headerValues := httpHeader.Values(headerLink) 394 | if len(headerValues) == 0 { 395 | return nil, nil 396 | } 397 | for _, encodedLink := range strings.Split(strings.Join(headerValues, ","), ",") { 398 | link, err := decodeLink(encodedLink) 399 | if err != nil { 400 | return nil, err 401 | } 402 | links = append(links, link) 403 | } 404 | return links, nil 405 | } 406 | 407 | func httpHeaderToNexusHeader(httpHeader http.Header, excludePrefixes ...string) Header { 408 | header := Header{} 409 | headerLoop: 410 | for k, v := range httpHeader { 411 | lowerK := strings.ToLower(k) 412 | for _, prefix := range excludePrefixes { 413 | if strings.HasPrefix(lowerK, prefix) { 414 | continue headerLoop 415 | } 416 | } 417 | // Nexus headers can only have single values, ignore multiple values. 418 | header[lowerK] = v[0] 419 | } 420 | return header 421 | } 422 | 423 | func addNexusHeaderToHTTPHeader(nexusHeader Header, httpHeader http.Header) http.Header { 424 | for k, v := range nexusHeader { 425 | httpHeader.Set(k, v) 426 | } 427 | return httpHeader 428 | } 429 | 430 | func addContextTimeoutToHTTPHeader(ctx context.Context, httpHeader http.Header) http.Header { 431 | deadline, ok := ctx.Deadline() 432 | if !ok { 433 | return httpHeader 434 | } 435 | httpHeader.Set(HeaderRequestTimeout, formatDuration(time.Until(deadline))) 436 | return httpHeader 437 | } 438 | 439 | // Link contains an URL and a Type that can be used to decode the URL. 440 | // Links can contain any arbitrary information as a percent-encoded URL. 441 | // It can be used to pass information about the caller to the handler, or vice-versa. 442 | type Link struct { 443 | // URL information about the link. 444 | // It must be URL percent-encoded. 445 | URL *url.URL 446 | // Type can describe an actual data type for decoding the URL. 447 | // Valid chars: alphanumeric, '_', '.', '/' 448 | Type string 449 | } 450 | 451 | const linkTypeKey = "type" 452 | 453 | // decodeLink encodes the link to Nexus-Link header value. 454 | // It follows the same format of HTTP Link header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link 455 | func encodeLink(link Link) (string, error) { 456 | if err := validateLinkURL(link.URL); err != nil { 457 | return "", fmt.Errorf("failed to encode link: %w", err) 458 | } 459 | if err := validateLinkType(link.Type); err != nil { 460 | return "", fmt.Errorf("failed to encode link: %w", err) 461 | } 462 | return fmt.Sprintf(`<%s>; %s="%s"`, link.URL.String(), linkTypeKey, link.Type), nil 463 | } 464 | 465 | // decodeLink decodes the Nexus-Link header values. 466 | // It must have the same format of HTTP Link header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link 467 | func decodeLink(encodedLink string) (Link, error) { 468 | var link Link 469 | encodedLink = strings.TrimSpace(encodedLink) 470 | if len(encodedLink) == 0 { 471 | return link, fmt.Errorf("failed to parse link header: value is empty") 472 | } 473 | 474 | if encodedLink[0] != '<' { 475 | return link, fmt.Errorf("failed to parse link header: invalid format: %s", encodedLink) 476 | } 477 | urlEnd := strings.Index(encodedLink, ">") 478 | if urlEnd == -1 { 479 | return link, fmt.Errorf("failed to parse link header: invalid format: %s", encodedLink) 480 | } 481 | urlStr := strings.TrimSpace(encodedLink[1:urlEnd]) 482 | if len(urlStr) == 0 { 483 | return link, fmt.Errorf("failed to parse link header: url is empty") 484 | } 485 | u, err := url.Parse(urlStr) 486 | if err != nil { 487 | return link, fmt.Errorf("failed to parse link header: invalid url: %s", urlStr) 488 | } 489 | if err := validateLinkURL(u); err != nil { 490 | return link, fmt.Errorf("failed to parse link header: %w", err) 491 | } 492 | link.URL = u 493 | 494 | params := strings.Split(encodedLink[urlEnd+1:], ";") 495 | // must contain at least one semi-colon, and first param must be empty since 496 | // it corresponds to the url part parsed above. 497 | if len(params) < 2 { 498 | return link, fmt.Errorf("failed to parse link header: invalid format: %s", encodedLink) 499 | } 500 | if strings.TrimSpace(params[0]) != "" { 501 | return link, fmt.Errorf("failed to parse link header: invalid format: %s", encodedLink) 502 | } 503 | 504 | typeKeyFound := false 505 | for _, param := range params[1:] { 506 | param = strings.TrimSpace(param) 507 | if len(param) == 0 { 508 | return link, fmt.Errorf("failed to parse link header: parameter is empty: %s", encodedLink) 509 | } 510 | kv := strings.SplitN(param, "=", 2) 511 | if len(kv) != 2 { 512 | return link, fmt.Errorf("failed to parse link header: invalid parameter format: %s", param) 513 | } 514 | key := strings.TrimSpace(kv[0]) 515 | val := strings.TrimSpace(kv[1]) 516 | if strings.HasPrefix(val, `"`) != strings.HasSuffix(val, `"`) { 517 | return link, fmt.Errorf( 518 | "failed to parse link header: parameter value missing double-quote: %s", 519 | param, 520 | ) 521 | } 522 | if strings.HasPrefix(val, `"`) { 523 | val = val[1 : len(val)-1] 524 | } 525 | if key == linkTypeKey { 526 | if err := validateLinkType(val); err != nil { 527 | return link, fmt.Errorf("failed to parse link header: %w", err) 528 | } 529 | link.Type = val 530 | typeKeyFound = true 531 | } 532 | } 533 | if !typeKeyFound { 534 | return link, fmt.Errorf( 535 | "failed to parse link header: %q key not found: %s", 536 | linkTypeKey, 537 | encodedLink, 538 | ) 539 | } 540 | 541 | return link, nil 542 | } 543 | 544 | func validateLinkURL(value *url.URL) error { 545 | if value == nil || value.String() == "" { 546 | return fmt.Errorf("url is empty") 547 | } 548 | _, err := url.ParseQuery(value.RawQuery) 549 | if err != nil { 550 | return fmt.Errorf("url query not percent-encoded: %s", value) 551 | } 552 | return nil 553 | } 554 | 555 | func validateLinkType(value string) error { 556 | if len(value) == 0 { 557 | return fmt.Errorf("link type is empty") 558 | } 559 | for _, c := range value { 560 | if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '_' && c != '.' && c != '/' { 561 | return fmt.Errorf("link type contains invalid char (valid chars: alphanumeric, '_', '.', '/')") 562 | } 563 | } 564 | return nil 565 | } 566 | 567 | var durationRegexp = regexp.MustCompile(`^(\d+(?:\.\d+)?)(ms|s|m)$`) 568 | 569 | func parseDuration(value string) (time.Duration, error) { 570 | m := durationRegexp.FindStringSubmatch(value) 571 | if len(m) == 0 { 572 | return 0, fmt.Errorf("invalid duration: %q", value) 573 | } 574 | v, err := strconv.ParseFloat(m[1], 64) 575 | if err != nil { 576 | return 0, err 577 | } 578 | 579 | switch m[2] { 580 | case "ms": 581 | return time.Millisecond * time.Duration(v), nil 582 | case "s": 583 | return time.Millisecond * time.Duration(v*1e3), nil 584 | case "m": 585 | return time.Millisecond * time.Duration(v*1e3*60), nil 586 | } 587 | panic("unreachable") 588 | } 589 | 590 | // formatDuration converts a duration into a string representation in millisecond resolution. 591 | func formatDuration(d time.Duration) string { 592 | return strconv.FormatInt(d.Milliseconds(), 10) + "ms" 593 | } 594 | -------------------------------------------------------------------------------- /nexus/api_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/url" 7 | "reflect" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestFailure_JSONMarshalling(t *testing.T) { 15 | // This test isn't strictly required, it's left to demonstrate how to use Failures. 16 | 17 | type testcase struct { 18 | message string 19 | details any 20 | metadata map[string]string 21 | serialized string 22 | } 23 | cases := []testcase{ 24 | { 25 | message: "simple", 26 | details: "details", 27 | serialized: `{ 28 | "message": "simple", 29 | "details": "details" 30 | }`, 31 | }, 32 | { 33 | message: "complex", 34 | metadata: map[string]string{"meta": "data"}, 35 | details: struct { 36 | M map[string]string 37 | I int64 38 | }{ 39 | M: map[string]string{"a": "b"}, 40 | I: 654, 41 | }, 42 | serialized: `{ 43 | "message": "complex", 44 | "metadata": { 45 | "meta": "data" 46 | }, 47 | "details": { 48 | "M": { 49 | "a": "b" 50 | }, 51 | "I": 654 52 | } 53 | }`, 54 | }, 55 | } 56 | for _, tc := range cases { 57 | tc := tc 58 | t.Run(tc.message, func(t *testing.T) { 59 | serializedDetails, err := json.MarshalIndent(tc.details, "", "\t") 60 | require.NoError(t, err) 61 | source, err := json.MarshalIndent(Failure{tc.message, tc.metadata, serializedDetails}, "", "\t") 62 | require.NoError(t, err) 63 | require.Equal(t, tc.serialized, string(source)) 64 | 65 | var failure Failure 66 | err = json.Unmarshal(source, &failure) 67 | require.NoError(t, err) 68 | 69 | require.Equal(t, tc.message, failure.Message) 70 | require.Equal(t, tc.metadata, failure.Metadata) 71 | 72 | detailsPointer := reflect.New(reflect.TypeOf(tc.details)).Interface() 73 | err = json.Unmarshal(failure.Details, detailsPointer) 74 | details := reflect.ValueOf(detailsPointer).Elem().Interface() 75 | require.NoError(t, err) 76 | require.Equal(t, tc.details, details) 77 | }) 78 | } 79 | } 80 | 81 | func TestAddLinksToHeader(t *testing.T) { 82 | type testcase struct { 83 | name string 84 | input []Link 85 | output http.Header 86 | errMsg string 87 | } 88 | 89 | cases := []testcase{ 90 | { 91 | name: "single link", 92 | input: []Link{{ 93 | URL: &url.URL{ 94 | Scheme: "https", 95 | Host: "example.com", 96 | Path: "/path/to/something", 97 | RawQuery: "param=value", 98 | }, 99 | Type: "url", 100 | }}, 101 | output: http.Header{ 102 | http.CanonicalHeaderKey(headerLink): []string{ 103 | `; type="url"`, 104 | }, 105 | }, 106 | }, 107 | { 108 | name: "multiple links", 109 | input: []Link{ 110 | { 111 | URL: &url.URL{ 112 | Scheme: "https", 113 | Host: "example.com", 114 | Path: "/path/to/something", 115 | RawQuery: "param=value", 116 | }, 117 | Type: "url", 118 | }, 119 | { 120 | URL: &url.URL{ 121 | Scheme: "https", 122 | Host: "foo.com", 123 | Path: "/path/to/something", 124 | RawQuery: "bar=value", 125 | }, 126 | Type: "url", 127 | }, 128 | }, 129 | output: http.Header{ 130 | http.CanonicalHeaderKey(headerLink): []string{ 131 | `; type="url"`, 132 | `; type="url"`, 133 | }, 134 | }, 135 | }, 136 | { 137 | name: "invalid link", 138 | input: []Link{{ 139 | URL: &url.URL{ 140 | Scheme: "https", 141 | Host: "example.com", 142 | Path: "/path/to/something", 143 | RawQuery: "param=value%", 144 | }, 145 | Type: "url", 146 | }}, 147 | errMsg: "failed to encode link", 148 | }, 149 | } 150 | 151 | for _, tc := range cases { 152 | t.Run(tc.name, func(t *testing.T) { 153 | output := http.Header{} 154 | err := addLinksToHTTPHeader(tc.input, output) 155 | if tc.errMsg != "" { 156 | require.ErrorContains(t, err, tc.errMsg) 157 | } else { 158 | require.NoError(t, err) 159 | require.Equal(t, tc.output, output) 160 | } 161 | }) 162 | } 163 | } 164 | 165 | func TestGetLinksFromHeader(t *testing.T) { 166 | type testcase struct { 167 | name string 168 | input http.Header 169 | output []Link 170 | errMsg string 171 | } 172 | 173 | cases := []testcase{ 174 | { 175 | name: "single link", 176 | input: http.Header{ 177 | http.CanonicalHeaderKey(headerLink): []string{ 178 | `; type="url"`, 179 | }, 180 | }, 181 | output: []Link{{ 182 | URL: &url.URL{ 183 | Scheme: "https", 184 | Host: "example.com", 185 | Path: "/path/to/something", 186 | RawQuery: "param=value", 187 | }, 188 | Type: "url", 189 | }}, 190 | }, 191 | { 192 | name: "multiple links", 193 | input: http.Header{ 194 | http.CanonicalHeaderKey(headerLink): []string{ 195 | `; type="url"`, 196 | `; type="url"`, 197 | }, 198 | }, 199 | output: []Link{ 200 | { 201 | URL: &url.URL{ 202 | Scheme: "https", 203 | Host: "example.com", 204 | Path: "/path/to/something", 205 | RawQuery: "param=value", 206 | }, 207 | Type: "url", 208 | }, 209 | { 210 | URL: &url.URL{ 211 | Scheme: "https", 212 | Host: "foo.com", 213 | Path: "/path/to/something", 214 | RawQuery: "bar=value", 215 | }, 216 | Type: "url", 217 | }, 218 | }, 219 | }, 220 | { 221 | name: "multiple links single header", 222 | input: http.Header{ 223 | http.CanonicalHeaderKey(headerLink): []string{ 224 | `; type="url", ; type="url"`, 225 | }, 226 | }, 227 | output: []Link{ 228 | { 229 | URL: &url.URL{ 230 | Scheme: "https", 231 | Host: "example.com", 232 | Path: "/path/to/something", 233 | RawQuery: "param=value", 234 | }, 235 | Type: "url", 236 | }, 237 | { 238 | URL: &url.URL{ 239 | Scheme: "https", 240 | Host: "foo.com", 241 | Path: "/path/to/something", 242 | RawQuery: "bar=value", 243 | }, 244 | Type: "url", 245 | }, 246 | }, 247 | }, 248 | { 249 | name: "invalid header", 250 | input: http.Header{ 251 | http.CanonicalHeaderKey(headerLink): []string{ 252 | ` type="url"`, 253 | }, 254 | }, 255 | errMsg: "failed to parse link header", 256 | }, 257 | } 258 | 259 | for _, tc := range cases { 260 | t.Run(tc.name, func(t *testing.T) { 261 | output, err := getLinksFromHeader(tc.input) 262 | if tc.errMsg != "" { 263 | require.ErrorContains(t, err, tc.errMsg) 264 | } else { 265 | require.NoError(t, err) 266 | require.Equal(t, tc.output, output) 267 | } 268 | }) 269 | } 270 | } 271 | 272 | func TestEncodeLink(t *testing.T) { 273 | type testcase struct { 274 | name string 275 | input Link 276 | output string 277 | errMsg string 278 | } 279 | 280 | cases := []testcase{ 281 | { 282 | name: "valid", 283 | input: Link{ 284 | URL: &url.URL{ 285 | Scheme: "https", 286 | Host: "example.com", 287 | Path: "/path/to/something", 288 | RawQuery: "param1=value1¶m2=value2", 289 | }, 290 | Type: "text/plain", 291 | }, 292 | output: `; type="text/plain"`, 293 | }, 294 | { 295 | name: "valid custom URL", 296 | input: Link{ 297 | URL: &url.URL{ 298 | Scheme: "nexus", 299 | Path: "/path/to/something", 300 | RawQuery: "param1=value1", 301 | }, 302 | Type: "nexus.data_type", 303 | }, 304 | output: `; type="nexus.data_type"`, 305 | }, 306 | { 307 | name: "invalid url empty", 308 | input: Link{ 309 | URL: &url.URL{}, 310 | Type: "text/plain", 311 | }, 312 | errMsg: "failed to encode link", 313 | }, 314 | { 315 | name: "invalid query not percent-encoded", 316 | input: Link{ 317 | URL: &url.URL{ 318 | Scheme: "https", 319 | Host: "example.com", 320 | Path: "/path/to/something", 321 | RawQuery: "param1=value1¶m2=value2;", 322 | }, 323 | Type: "text/plain", 324 | }, 325 | errMsg: "failed to encode link", 326 | }, 327 | { 328 | name: "invalid type empty", 329 | input: Link{ 330 | URL: &url.URL{ 331 | Scheme: "https", 332 | Host: "example.com", 333 | Path: "/path/to/something", 334 | RawQuery: "param1=value1¶m2=value2", 335 | }, 336 | Type: "", 337 | }, 338 | errMsg: "failed to encode link", 339 | }, 340 | { 341 | name: "invalid type invalid chars", 342 | input: Link{ 343 | URL: &url.URL{ 344 | Scheme: "https", 345 | Host: "example.com", 346 | Path: "/path/to/something", 347 | RawQuery: "param1=value1¶m2=value2", 348 | }, 349 | Type: "text/plain;", 350 | }, 351 | errMsg: "failed to encode link", 352 | }, 353 | } 354 | 355 | for _, tc := range cases { 356 | t.Run(tc.name, func(t *testing.T) { 357 | output, err := encodeLink(tc.input) 358 | if tc.errMsg != "" { 359 | require.ErrorContains(t, err, tc.errMsg) 360 | } else { 361 | require.NoError(t, err) 362 | require.Equal(t, tc.output, output) 363 | } 364 | }) 365 | } 366 | } 367 | 368 | func TestDecodeLink(t *testing.T) { 369 | type testcase struct { 370 | name string 371 | input string 372 | output Link 373 | errMsg string 374 | } 375 | 376 | cases := []testcase{ 377 | { 378 | name: "valid", 379 | input: `; type="text/plain"`, 380 | output: Link{ 381 | URL: &url.URL{ 382 | Scheme: "https", 383 | Host: "example.com", 384 | Path: "/path/to/something", 385 | RawQuery: "param1=value1¶m2=value2", 386 | }, 387 | Type: "text/plain", 388 | }, 389 | }, 390 | { 391 | name: "valid multiple params", 392 | input: `; type="text/plain"; Param="value"`, 393 | output: Link{ 394 | URL: &url.URL{ 395 | Scheme: "https", 396 | Host: "example.com", 397 | Path: "/path/to/something", 398 | RawQuery: "param1=value1¶m2=value2", 399 | }, 400 | Type: "text/plain", 401 | }, 402 | }, 403 | { 404 | name: "valid param not quoted", 405 | input: `; type=text/plain`, 406 | output: Link{ 407 | URL: &url.URL{ 408 | Scheme: "https", 409 | Host: "example.com", 410 | Path: "/path/to/something", 411 | RawQuery: "param1=value1¶m2=value2", 412 | }, 413 | Type: "text/plain", 414 | }, 415 | }, 416 | { 417 | name: "valid custom URL", 418 | input: `; type="nexus.data_type"`, 419 | output: Link{ 420 | URL: &url.URL{ 421 | Scheme: "nexus", 422 | Path: "/path/to/something", 423 | RawQuery: "param=value", 424 | }, 425 | Type: "nexus.data_type", 426 | }, 427 | }, 428 | { 429 | name: "invalid url", 430 | input: ``, 431 | errMsg: "failed to parse link header", 432 | }, 433 | { 434 | name: "invalid trailing semi-colon", 435 | input: `; type="text/plain";`, 436 | errMsg: "failed to parse link header", 437 | }, 438 | { 439 | name: "invalid empty param part", 440 | input: `; ; type="text/plain`, 441 | errMsg: "failed to parse link header", 442 | }, 443 | { 444 | name: "invalid no type param trailing semi-colon", 445 | input: `;`, 446 | errMsg: "failed to parse link header", 447 | }, 448 | { 449 | name: "invalid url not enclosed", 450 | input: `https://example.com/path/to/something?param1=value1¶m2=value2; type="text/plain"`, 451 | errMsg: "failed to parse link header", 452 | }, 453 | { 454 | name: "invalid url missing closing bracket", 455 | input: `; type="text/plain"`, 461 | errMsg: "failed to parse link header", 462 | }, 463 | { 464 | name: "invalid param missing quote", 465 | input: `https://example.com/path/to/something?param1=value1¶m2=value2>; type="text/plain`, 466 | errMsg: "failed to parse link header", 467 | }, 468 | { 469 | name: "invalid multiple params missing semi-colon", 470 | input: `https://example.com/path/to/something?param1=value1¶m2=value2>; type="text/plain" Param=value`, 471 | errMsg: "failed to parse link header", 472 | }, 473 | { 474 | name: "invalid missing semi-colon after url", 475 | input: `https://example.com/path/to/something?param1=value1¶m2=value2> type="text/plain"`, 476 | errMsg: "failed to parse link header", 477 | }, 478 | { 479 | name: "invalid param missing value", 480 | input: `https://example.com/path/to/something?param1=value1¶m2=value2>; type`, 481 | errMsg: "failed to parse link header", 482 | }, 483 | { 484 | name: "invalid param missing value with equal sign", 485 | input: `; type=`, 486 | errMsg: "failed to parse link header", 487 | }, 488 | { 489 | name: "invalid missing type key", 490 | input: ``, 491 | errMsg: "failed to parse link header", 492 | }, 493 | { 494 | name: "invalid url empty", 495 | input: `<>; type="text/plain"`, 496 | errMsg: "failed to parse link header", 497 | }, 498 | } 499 | 500 | for _, tc := range cases { 501 | t.Run(tc.name, func(t *testing.T) { 502 | output, err := decodeLink(tc.input) 503 | if tc.errMsg != "" { 504 | require.ErrorContains(t, err, tc.errMsg) 505 | } else { 506 | require.NoError(t, err) 507 | require.Equal(t, tc.output, output) 508 | } 509 | }) 510 | } 511 | } 512 | 513 | func TestParseDuration(t *testing.T) { 514 | _, err := parseDuration("invalid") 515 | require.ErrorContains(t, err, "invalid duration:") 516 | d, err := parseDuration("10ms") 517 | require.NoError(t, err) 518 | require.Equal(t, 10*time.Millisecond, d) 519 | d, err = parseDuration("10.1ms") 520 | require.NoError(t, err) 521 | require.Equal(t, 10*time.Millisecond, d) 522 | d, err = parseDuration("1s") 523 | require.NoError(t, err) 524 | require.Equal(t, 1*time.Second, d) 525 | d, err = parseDuration("999m") 526 | require.NoError(t, err) 527 | require.Equal(t, 999*time.Minute, d) 528 | d, err = parseDuration("1.3s") 529 | require.NoError(t, err) 530 | require.Equal(t, 1300*time.Millisecond, d) 531 | } 532 | -------------------------------------------------------------------------------- /nexus/cancel_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type asyncWithCancelHandler struct { 12 | expectHeader bool 13 | UnimplementedHandler 14 | } 15 | 16 | func (h *asyncWithCancelHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 17 | return &HandlerStartOperationResultAsync{ 18 | OperationToken: "a/sync", 19 | }, nil 20 | } 21 | 22 | func (h *asyncWithCancelHandler) CancelOperation(ctx context.Context, service, operation, token string, options CancelOperationOptions) error { 23 | if service != testService { 24 | return HandlerErrorf(HandlerErrorTypeBadRequest, "unexpected service: %s", service) 25 | } 26 | if operation != "f/o/o" { 27 | return HandlerErrorf(HandlerErrorTypeBadRequest, "expected operation to be 'foo', got: %s", operation) 28 | } 29 | if token != "a/sync" { 30 | return HandlerErrorf(HandlerErrorTypeBadRequest, "expected operation ID to be 'async', got: %s", token) 31 | } 32 | if h.expectHeader && options.Header.Get("foo") != "bar" { 33 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'foo' request header") 34 | } 35 | if options.Header.Get("User-Agent") != userAgent { 36 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'User-Agent' header: %q", options.Header.Get("User-Agent")) 37 | } 38 | return nil 39 | } 40 | 41 | func TestCancel_HandleFromStart(t *testing.T) { 42 | ctx, client, teardown := setup(t, &asyncWithCancelHandler{expectHeader: true}) 43 | defer teardown() 44 | 45 | result, err := client.StartOperation(ctx, "f/o/o", nil, StartOperationOptions{}) 46 | require.NoError(t, err) 47 | handle := result.Pending 48 | require.NotNil(t, handle) 49 | err = handle.Cancel(ctx, CancelOperationOptions{ 50 | Header: Header{"foo": "bar"}, 51 | }) 52 | require.NoError(t, err) 53 | } 54 | 55 | func TestCancel_HandleFromClient(t *testing.T) { 56 | ctx, client, teardown := setup(t, &asyncWithCancelHandler{}) 57 | defer teardown() 58 | 59 | handle, err := client.NewHandle("f/o/o", "a/sync") 60 | require.NoError(t, err) 61 | err = handle.Cancel(ctx, CancelOperationOptions{}) 62 | require.NoError(t, err) 63 | } 64 | 65 | type echoTimeoutAsyncWithCancelHandler struct { 66 | expectedTimeout time.Duration 67 | UnimplementedHandler 68 | } 69 | 70 | func (h *echoTimeoutAsyncWithCancelHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 71 | return &HandlerStartOperationResultAsync{ 72 | OperationToken: "timeout", 73 | }, nil 74 | } 75 | 76 | func (h *echoTimeoutAsyncWithCancelHandler) CancelOperation(ctx context.Context, service, operation, token string, options CancelOperationOptions) error { 77 | deadline, set := ctx.Deadline() 78 | if h.expectedTimeout > 0 && !set { 79 | return HandlerErrorf(HandlerErrorTypeBadRequest, "expected operation to have timeout set but context has no deadline") 80 | } 81 | if h.expectedTimeout <= 0 && set { 82 | return HandlerErrorf(HandlerErrorTypeBadRequest, "expected operation to have no timeout but context has deadline set") 83 | } 84 | timeout := time.Until(deadline) 85 | if timeout > h.expectedTimeout { 86 | return HandlerErrorf(HandlerErrorTypeBadRequest, "operation has timeout (%s) greater than expected (%s)", timeout.String(), h.expectedTimeout.String()) 87 | } 88 | return nil 89 | } 90 | 91 | func TestCancel_ContextDeadlinePropagated(t *testing.T) { 92 | ctx, client, teardown := setup(t, &echoTimeoutAsyncWithCancelHandler{expectedTimeout: testTimeout}) 93 | defer teardown() 94 | 95 | handle, err := client.NewHandle("foo", "timeout") 96 | require.NoError(t, err) 97 | err = handle.Cancel(ctx, CancelOperationOptions{}) 98 | require.NoError(t, err) 99 | } 100 | 101 | func TestCancel_RequestTimeoutHeaderOverridesContextDeadline(t *testing.T) { 102 | timeout := 100 * time.Millisecond 103 | // relies on ctx returned here having default testTimeout set greater than expected timeout 104 | ctx, client, teardown := setup(t, &echoTimeoutAsyncWithCancelHandler{expectedTimeout: timeout}) 105 | defer teardown() 106 | 107 | handle, err := client.NewHandle("foo", "timeout") 108 | require.NoError(t, err) 109 | err = handle.Cancel(ctx, CancelOperationOptions{Header: Header{HeaderRequestTimeout: formatDuration(timeout)}}) 110 | require.NoError(t, err) 111 | } 112 | 113 | func TestCancel_TimeoutNotPropagated(t *testing.T) { 114 | _, client, teardown := setup(t, &echoTimeoutAsyncWithCancelHandler{}) 115 | defer teardown() 116 | 117 | handle, err := client.NewHandle("foo", "timeout") 118 | require.NoError(t, err) 119 | err = handle.Cancel(context.Background(), CancelOperationOptions{}) 120 | require.NoError(t, err) 121 | } 122 | -------------------------------------------------------------------------------- /nexus/client.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "maps" 11 | "math" 12 | "net/http" 13 | "net/url" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "github.com/google/uuid" 19 | ) 20 | 21 | // HTTPClientOptions are options for creating an [HTTPClient]. 22 | type HTTPClientOptions struct { 23 | // Base URL for all requests. Required. 24 | BaseURL string 25 | // Service name. Required. 26 | Service string 27 | // A function for making HTTP requests. 28 | // Defaults to [http.DefaultClient.Do]. 29 | HTTPCaller func(*http.Request) (*http.Response, error) 30 | // A [Serializer] to customize client serialization behavior. 31 | // By default the client handles JSONables, byte slices, and nil. 32 | Serializer Serializer 33 | // A [FailureConverter] to convert a [Failure] instance to and from an [error]. Defaults to 34 | // [DefaultFailureConverter]. 35 | FailureConverter FailureConverter 36 | // UseOperationID instructs the client to use an older format of the protocol where operation ID is sent 37 | // as part of the URL path. 38 | // This flag will be removed in a future release. 39 | // 40 | // NOTE: Experimental 41 | UseOperationID bool 42 | } 43 | 44 | // User-Agent header set on HTTP requests. 45 | const userAgent = "Nexus-go-sdk/" + version 46 | 47 | const headerUserAgent = "User-Agent" 48 | 49 | var errEmptyOperationName = errors.New("empty operation name") 50 | 51 | var errEmptyOperationToken = errors.New("empty operation token") 52 | 53 | var errOperationWaitTimeout = errors.New("operation wait timeout") 54 | 55 | // Error that indicates a client encountered something unexpected in the server's response. 56 | type UnexpectedResponseError struct { 57 | // Error message. 58 | Message string 59 | // Optional failure that may have been emedded in the response. 60 | Failure *Failure 61 | // Additional transport specific details. 62 | // For HTTP, this would include the HTTP response. The response body will have already been read into memory and 63 | // does not need to be closed. 64 | Details any 65 | } 66 | 67 | // Error implements the error interface. 68 | func (e *UnexpectedResponseError) Error() string { 69 | return e.Message 70 | } 71 | 72 | func newUnexpectedResponseError(message string, response *http.Response, body []byte) error { 73 | var failure *Failure 74 | if isMediaTypeJSON(response.Header.Get("Content-Type")) { 75 | if err := json.Unmarshal(body, &failure); err == nil && failure.Message != "" { 76 | message += ": " + failure.Message 77 | } 78 | } 79 | 80 | return &UnexpectedResponseError{ 81 | Message: message, 82 | Details: response, 83 | Failure: failure, 84 | } 85 | } 86 | 87 | // An HTTPClient makes Nexus service requests as defined in the [Nexus HTTP API]. 88 | // 89 | // It can start a new operation and get an [OperationHandle] to an existing, asynchronous operation. 90 | // 91 | // Use an [OperationHandle] to cancel, get the result of, and get information about asynchronous operations. 92 | // 93 | // OperationHandles can be obtained either by starting new operations or by calling [HTTPClient.NewHandle] for existing 94 | // operations. 95 | // 96 | // [Nexus HTTP API]: https://github.com/nexus-rpc/api 97 | type HTTPClient struct { 98 | // The options this client was created with after applying defaults. 99 | options HTTPClientOptions 100 | serviceBaseURL *url.URL 101 | } 102 | 103 | // NewHTTPClient creates a new [HTTPClient] from provided [HTTPClientOptions]. 104 | // BaseURL and Service are required. 105 | func NewHTTPClient(options HTTPClientOptions) (*HTTPClient, error) { 106 | if options.HTTPCaller == nil { 107 | options.HTTPCaller = http.DefaultClient.Do 108 | } 109 | if options.BaseURL == "" { 110 | return nil, errors.New("empty BaseURL") 111 | } 112 | if options.Service == "" { 113 | return nil, errors.New("empty Service") 114 | } 115 | var baseURL *url.URL 116 | var err error 117 | baseURL, err = url.Parse(options.BaseURL) 118 | if err != nil { 119 | return nil, err 120 | } 121 | if baseURL.Scheme != "http" && baseURL.Scheme != "https" { 122 | return nil, fmt.Errorf("invalid URL scheme: %s", baseURL.Scheme) 123 | } 124 | if options.Serializer == nil { 125 | options.Serializer = defaultSerializer 126 | } 127 | if options.FailureConverter == nil { 128 | options.FailureConverter = defaultFailureConverter 129 | } 130 | 131 | return &HTTPClient{ 132 | options: options, 133 | serviceBaseURL: baseURL, 134 | }, nil 135 | } 136 | 137 | // ClientStartOperationResult is the return type of [HTTPClient.StartOperation]. 138 | // One and only one of Successful or Pending will be non-nil. 139 | type ClientStartOperationResult[T any] struct { 140 | // Set when start completes synchronously and successfully. 141 | // 142 | // If T is a [LazyValue], ensure that your consume it or read the underlying content in its entirety and close it to 143 | // free up the underlying connection. 144 | Successful T 145 | // Set when the handler indicates that it started an asynchronous operation. 146 | // The attached handle can be used to perform actions such as cancel the operation or get its result. 147 | Pending *OperationHandle[T] 148 | // Links contain information about the operations done by the handler. 149 | Links []Link 150 | } 151 | 152 | // StartOperation calls the configured Nexus endpoint to start an operation. 153 | // 154 | // This method has the following possible outcomes: 155 | // 156 | // 1. The operation completes successfully. The result of this call will be set as a [LazyValue] in 157 | // ClientStartOperationResult.Successful and must be consumed to free up the underlying connection. 158 | // 159 | // 2. The operation was started and the handler has indicated that it will complete asynchronously. An 160 | // [OperationHandle] will be returned as ClientStartOperationResult.Pending, which can be used to perform actions 161 | // such as getting its result. 162 | // 163 | // 3. The operation was unsuccessful. The returned result will be nil and error will be an 164 | // [OperationError]. 165 | // 166 | // 4. Any other error. 167 | func (c *HTTPClient) StartOperation( 168 | ctx context.Context, 169 | operation string, 170 | input any, 171 | options StartOperationOptions, 172 | ) (*ClientStartOperationResult[*LazyValue], error) { 173 | var reader *Reader 174 | if r, ok := input.(*Reader); ok { 175 | // Close the input reader in case we error before sending the HTTP request (which may double close but 176 | // that's fine since we ignore the error). 177 | defer r.Close() 178 | reader = r 179 | } else { 180 | content, ok := input.(*Content) 181 | if !ok { 182 | var err error 183 | content, err = c.options.Serializer.Serialize(input) 184 | if err != nil { 185 | return nil, err 186 | } 187 | } 188 | header := maps.Clone(content.Header) 189 | if header == nil { 190 | header = make(Header, 1) 191 | } 192 | header["length"] = strconv.Itoa(len(content.Data)) 193 | 194 | reader = &Reader{ 195 | io.NopCloser(bytes.NewReader(content.Data)), 196 | header, 197 | } 198 | } 199 | 200 | url := c.serviceBaseURL.JoinPath(url.PathEscape(c.options.Service), url.PathEscape(operation)) 201 | 202 | if options.CallbackURL != "" { 203 | q := url.Query() 204 | q.Set(queryCallbackURL, options.CallbackURL) 205 | url.RawQuery = q.Encode() 206 | } 207 | request, err := http.NewRequestWithContext(ctx, "POST", url.String(), reader) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | if options.RequestID == "" { 213 | options.RequestID = uuid.NewString() 214 | } 215 | request.Header.Set(headerRequestID, options.RequestID) 216 | request.Header.Set(headerUserAgent, userAgent) 217 | addContentHeaderToHTTPHeader(reader.Header, request.Header) 218 | addCallbackHeaderToHTTPHeader(options.CallbackHeader, request.Header) 219 | if err := addLinksToHTTPHeader(options.Links, request.Header); err != nil { 220 | return nil, fmt.Errorf("failed to serialize links into header: %w", err) 221 | } 222 | addContextTimeoutToHTTPHeader(ctx, request.Header) 223 | addNexusHeaderToHTTPHeader(options.Header, request.Header) 224 | 225 | response, err := c.options.HTTPCaller(request) 226 | if err != nil { 227 | return nil, err 228 | } 229 | 230 | links, err := getLinksFromHeader(response.Header) 231 | if err != nil { 232 | // Have to read body here to check if it is a Failure. 233 | body, err := readAndReplaceBody(response) 234 | if err != nil { 235 | return nil, err 236 | } 237 | return nil, fmt.Errorf( 238 | "%w: %w", 239 | newUnexpectedResponseError( 240 | fmt.Sprintf("invalid links header: %q", response.Header.Values(headerLink)), 241 | response, 242 | body, 243 | ), 244 | err, 245 | ) 246 | } 247 | 248 | // Do not close response body here to allow successful result to read it. 249 | if response.StatusCode == http.StatusOK { 250 | return &ClientStartOperationResult[*LazyValue]{ 251 | Successful: &LazyValue{ 252 | serializer: c.options.Serializer, 253 | Reader: &Reader{ 254 | response.Body, 255 | prefixStrippedHTTPHeaderToNexusHeader(response.Header, "content-"), 256 | }, 257 | }, 258 | Links: links, 259 | }, nil 260 | } 261 | 262 | // Do this once here and make sure it doesn't leak. 263 | body, err := readAndReplaceBody(response) 264 | if err != nil { 265 | return nil, err 266 | } 267 | 268 | switch response.StatusCode { 269 | case http.StatusCreated: 270 | info, err := operationInfoFromResponse(response, body) 271 | if err != nil { 272 | return nil, err 273 | } 274 | if info.State != OperationStateRunning { 275 | return nil, newUnexpectedResponseError(fmt.Sprintf("invalid operation state in response info: %q", info.State), response, body) 276 | } 277 | if info.Token == "" && info.ID != "" { 278 | info.Token = info.ID 279 | } 280 | handle, err := c.NewHandle(operation, info.Token) 281 | if err != nil { 282 | return nil, newUnexpectedResponseError("empty operation token in response", response, body) 283 | } 284 | return &ClientStartOperationResult[*LazyValue]{ 285 | Pending: handle, 286 | Links: links, 287 | }, nil 288 | case statusOperationFailed: 289 | state, err := getUnsuccessfulStateFromHeader(response, body) 290 | if err != nil { 291 | return nil, err 292 | } 293 | 294 | failure, err := c.failureFromResponse(response, body) 295 | if err != nil { 296 | return nil, err 297 | } 298 | 299 | failureErr := c.options.FailureConverter.FailureToError(failure) 300 | return nil, &OperationError{ 301 | State: state, 302 | Cause: failureErr, 303 | } 304 | default: 305 | return nil, c.bestEffortHandlerErrorFromResponse(response, body) 306 | } 307 | } 308 | 309 | // ExecuteOperationOptions are options for [HTTPClient.ExecuteOperation]. 310 | type ExecuteOperationOptions struct { 311 | // Callback URL to provide to the handle for receiving async operation completions. Optional. 312 | // Even though Client.ExecuteOperation waits for operation completion, some applications may want to set this 313 | // callback as a fallback mechanism. 314 | CallbackURL string 315 | // Optional header fields set by a client that are required to be attached to the callback request when an 316 | // asynchronous operation completes. 317 | CallbackHeader Header 318 | // Request ID that may be used by the server handler to dedupe this start request. 319 | // By default a v4 UUID will be generated by the client. 320 | RequestID string 321 | // Links contain arbitrary caller information. Handlers may use these links as 322 | // metadata on resources associated with and operation. 323 | Links []Link 324 | // Header to attach to start and get-result requests. Optional. 325 | // 326 | // Header values set here will overwrite any SDK-provided values for the same key. 327 | // 328 | // Header keys with the "content-" prefix are reserved for [Serializer] headers and should not be set in the 329 | // client API; they are not available to server [Handler] and [Operation] implementations. 330 | Header Header 331 | // Duration to wait for operation completion. 332 | // 333 | // ⚠ NOTE: unlike GetOperationResultOptions.Wait, zero and negative values are considered effectively infinite. 334 | Wait time.Duration 335 | } 336 | 337 | // ExecuteOperation is a helper for starting an operation and waiting for its completion. 338 | // 339 | // For asynchronous operations, the client will long poll for their result, issuing one or more requests until the 340 | // wait period provided via [ExecuteOperationOptions] exceeds, in which case an [ErrOperationStillRunning] error is 341 | // returned. 342 | // 343 | // The wait time is capped to the deadline of the provided context. Make sure to handle both context deadline errors and 344 | // [ErrOperationStillRunning]. 345 | // 346 | // Note that the wait period is enforced by the server and may not be respected if the server is misbehaving. Set the 347 | // context deadline to the max allowed wait period to ensure this call returns in a timely fashion. 348 | // 349 | // ⚠️ If this method completes successfully, the returned response's body must be read in its entirety and closed to 350 | // free up the underlying connection. 351 | func (c *HTTPClient) ExecuteOperation(ctx context.Context, operation string, input any, options ExecuteOperationOptions) (*LazyValue, error) { 352 | so := StartOperationOptions{ 353 | CallbackURL: options.CallbackURL, 354 | CallbackHeader: options.CallbackHeader, 355 | RequestID: options.RequestID, 356 | Links: options.Links, 357 | Header: options.Header, 358 | } 359 | result, err := c.StartOperation(ctx, operation, input, so) 360 | if err != nil { 361 | return nil, err 362 | } 363 | if result.Successful != nil { 364 | return result.Successful, nil 365 | } 366 | handle := result.Pending 367 | gro := GetOperationResultOptions{ 368 | Header: options.Header, 369 | } 370 | if options.Wait <= 0 { 371 | gro.Wait = time.Duration(math.MaxInt64) 372 | } else { 373 | gro.Wait = options.Wait 374 | } 375 | return handle.GetResult(ctx, gro) 376 | } 377 | 378 | // NewHandle gets a handle to an asynchronous operation by name and token. 379 | // Does not incur a trip to the server. 380 | // Fails if provided an empty operation or token. 381 | func (c *HTTPClient) NewHandle(operation string, token string) (*OperationHandle[*LazyValue], error) { 382 | var es []error 383 | if operation == "" { 384 | es = append(es, errEmptyOperationName) 385 | } 386 | if token == "" { 387 | es = append(es, errEmptyOperationToken) 388 | } 389 | if len(es) > 0 { 390 | return nil, errors.Join(es...) 391 | } 392 | return &OperationHandle[*LazyValue]{ 393 | client: c, 394 | Operation: operation, 395 | ID: token, // Duplicate token as ID for the deprecation period. 396 | Token: token, 397 | }, nil 398 | } 399 | 400 | // readAndReplaceBody reads the response body in its entirety and closes it, and then replaces the original response 401 | // body with an in-memory buffer. 402 | // The body is replaced even when there was an error reading the entire body. 403 | func readAndReplaceBody(response *http.Response) ([]byte, error) { 404 | responseBody := response.Body 405 | body, err := io.ReadAll(responseBody) 406 | responseBody.Close() 407 | response.Body = io.NopCloser(bytes.NewReader(body)) 408 | return body, err 409 | } 410 | 411 | func operationInfoFromResponse(response *http.Response, body []byte) (*OperationInfo, error) { 412 | if !isMediaTypeJSON(response.Header.Get("Content-Type")) { 413 | return nil, newUnexpectedResponseError(fmt.Sprintf("invalid response content type: %q", response.Header.Get("Content-Type")), response, body) 414 | } 415 | var info OperationInfo 416 | if err := json.Unmarshal(body, &info); err != nil { 417 | return nil, err 418 | } 419 | return &info, nil 420 | } 421 | 422 | func (c *HTTPClient) failureFromResponse(response *http.Response, body []byte) (Failure, error) { 423 | if !isMediaTypeJSON(response.Header.Get("Content-Type")) { 424 | return Failure{}, newUnexpectedResponseError(fmt.Sprintf("invalid response content type: %q", response.Header.Get("Content-Type")), response, body) 425 | } 426 | var failure Failure 427 | err := json.Unmarshal(body, &failure) 428 | return failure, err 429 | } 430 | 431 | func (c *HTTPClient) failureFromResponseOrDefault(response *http.Response, body []byte, defaultMessage string) Failure { 432 | failure, err := c.failureFromResponse(response, body) 433 | if err != nil { 434 | failure.Message = defaultMessage 435 | } 436 | return failure 437 | } 438 | 439 | func (c *HTTPClient) failureErrorFromResponseOrDefault(response *http.Response, body []byte, defaultMessage string) error { 440 | failure := c.failureFromResponseOrDefault(response, body, defaultMessage) 441 | failureErr := c.options.FailureConverter.FailureToError(failure) 442 | return failureErr 443 | } 444 | 445 | func (c *HTTPClient) bestEffortHandlerErrorFromResponse(response *http.Response, body []byte) error { 446 | switch response.StatusCode { 447 | case http.StatusBadRequest: 448 | return &HandlerError{ 449 | Type: HandlerErrorTypeBadRequest, 450 | Cause: c.failureErrorFromResponseOrDefault(response, body, "bad request"), 451 | RetryBehavior: retryBehaviorFromHeader(response.Header), 452 | } 453 | case http.StatusUnauthorized: 454 | return &HandlerError{ 455 | Type: HandlerErrorTypeUnauthenticated, 456 | Cause: c.failureErrorFromResponseOrDefault(response, body, "unauthenticated"), 457 | RetryBehavior: retryBehaviorFromHeader(response.Header), 458 | } 459 | case http.StatusForbidden: 460 | return &HandlerError{ 461 | Type: HandlerErrorTypeUnauthorized, 462 | Cause: c.failureErrorFromResponseOrDefault(response, body, "unauthorized"), 463 | RetryBehavior: retryBehaviorFromHeader(response.Header), 464 | } 465 | case http.StatusNotFound: 466 | return &HandlerError{ 467 | Type: HandlerErrorTypeNotFound, 468 | Cause: c.failureErrorFromResponseOrDefault(response, body, "not found"), 469 | RetryBehavior: retryBehaviorFromHeader(response.Header), 470 | } 471 | case http.StatusTooManyRequests: 472 | return &HandlerError{ 473 | Type: HandlerErrorTypeResourceExhausted, 474 | Cause: c.failureErrorFromResponseOrDefault(response, body, "resource exhausted"), 475 | RetryBehavior: retryBehaviorFromHeader(response.Header), 476 | } 477 | case http.StatusInternalServerError: 478 | return &HandlerError{ 479 | Type: HandlerErrorTypeInternal, 480 | Cause: c.failureErrorFromResponseOrDefault(response, body, "internal error"), 481 | RetryBehavior: retryBehaviorFromHeader(response.Header), 482 | } 483 | case http.StatusNotImplemented: 484 | return &HandlerError{ 485 | Type: HandlerErrorTypeNotImplemented, 486 | Cause: c.failureErrorFromResponseOrDefault(response, body, "not implemented"), 487 | RetryBehavior: retryBehaviorFromHeader(response.Header), 488 | } 489 | case http.StatusServiceUnavailable: 490 | return &HandlerError{ 491 | Type: HandlerErrorTypeUnavailable, 492 | Cause: c.failureErrorFromResponseOrDefault(response, body, "unavailable"), 493 | RetryBehavior: retryBehaviorFromHeader(response.Header), 494 | } 495 | case StatusUpstreamTimeout: 496 | return &HandlerError{ 497 | Type: HandlerErrorTypeUpstreamTimeout, 498 | Cause: c.failureErrorFromResponseOrDefault(response, body, "upstream timeout"), 499 | RetryBehavior: retryBehaviorFromHeader(response.Header), 500 | } 501 | default: 502 | return newUnexpectedResponseError(fmt.Sprintf("unexpected response status: %q", response.Status), response, body) 503 | } 504 | } 505 | 506 | func retryBehaviorFromHeader(header http.Header) HandlerErrorRetryBehavior { 507 | switch strings.ToLower(header.Get(headerRetryable)) { 508 | case "true": 509 | return HandlerErrorRetryBehaviorRetryable 510 | case "false": 511 | return HandlerErrorRetryBehaviorNonRetryable 512 | default: 513 | return HandlerErrorRetryBehaviorUnspecified 514 | } 515 | } 516 | 517 | func getUnsuccessfulStateFromHeader(response *http.Response, body []byte) (OperationState, error) { 518 | state := OperationState(response.Header.Get(headerOperationState)) 519 | switch state { 520 | case OperationStateCanceled: 521 | return state, nil 522 | case OperationStateFailed: 523 | return state, nil 524 | default: 525 | return state, newUnexpectedResponseError(fmt.Sprintf("invalid operation state header: %q", state), response, body) 526 | } 527 | } 528 | -------------------------------------------------------------------------------- /nexus/client_example_test.go: -------------------------------------------------------------------------------- 1 | package nexus_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/nexus-rpc/sdk-go/nexus" 9 | ) 10 | 11 | type MyStruct struct { 12 | Field string 13 | } 14 | 15 | var ctx = context.Background() 16 | var client *nexus.HTTPClient 17 | 18 | func ExampleHTTPClient_StartOperation() { 19 | result, err := client.StartOperation(ctx, "example", MyStruct{Field: "value"}, nexus.StartOperationOptions{}) 20 | if err != nil { 21 | var OperationError *nexus.OperationError 22 | if errors.As(err, &OperationError) { // operation failed or canceled 23 | fmt.Printf("Operation unsuccessful with state: %s, failure message: %s\n", OperationError.State, OperationError.Cause.Error()) 24 | } 25 | var handlerError *nexus.HandlerError 26 | if errors.As(err, &handlerError) { 27 | fmt.Printf("Handler returned an error, type: %s, failure message: %s\n", handlerError.Type, handlerError.Cause.Error()) 28 | } 29 | // most other errors should be returned as *nexus.UnexpectedResponseError 30 | } 31 | if result.Successful != nil { // operation successful 32 | response := result.Successful 33 | // must consume the response to free up the underlying connection 34 | var output MyStruct 35 | _ = response.Consume(&output) 36 | fmt.Printf("Got response: %v\n", output) 37 | } else { // operation started asynchronously 38 | handle := result.Pending 39 | fmt.Printf("Started asynchronous operation with ID: %s\n", handle.ID) 40 | } 41 | } 42 | 43 | func ExampleHTTPClient_ExecuteOperation() { 44 | response, err := client.ExecuteOperation(ctx, "operation name", MyStruct{Field: "value"}, nexus.ExecuteOperationOptions{}) 45 | if err != nil { 46 | // handle nexus.OperationError, nexus.ErrOperationStillRunning and, context.DeadlineExceeded 47 | } 48 | // must close the returned response body and read it until EOF to free up the underlying connection 49 | var output MyStruct 50 | _ = response.Consume(&output) 51 | fmt.Printf("Got response: %v\n", output) 52 | } 53 | -------------------------------------------------------------------------------- /nexus/client_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestNewClient(t *testing.T) { 11 | var err error 12 | 13 | _, err = NewHTTPClient(HTTPClientOptions{BaseURL: "", Service: "ignored"}) 14 | require.ErrorContains(t, err, "empty BaseURL") 15 | 16 | _, err = NewHTTPClient(HTTPClientOptions{BaseURL: "-http://invalid", Service: "ignored"}) 17 | var urlError *url.Error 18 | require.ErrorAs(t, err, &urlError) 19 | 20 | _, err = NewHTTPClient(HTTPClientOptions{BaseURL: "smtp://example.com", Service: "ignored"}) 21 | require.ErrorContains(t, err, "invalid URL scheme: smtp") 22 | 23 | _, err = NewHTTPClient(HTTPClientOptions{BaseURL: "http://example.com", Service: "ignored"}) 24 | require.NoError(t, err) 25 | 26 | _, err = NewHTTPClient(HTTPClientOptions{BaseURL: "https://example.com", Service: ""}) 27 | require.ErrorContains(t, err, "empty Service") 28 | 29 | _, err = NewHTTPClient(HTTPClientOptions{BaseURL: "https://example.com", Service: "valid"}) 30 | require.NoError(t, err) 31 | } 32 | -------------------------------------------------------------------------------- /nexus/completion.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "log/slog" 9 | "maps" 10 | "net/http" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | // NewCompletionHTTPRequest creates an HTTP request that delivers an operation completion to a given URL. 16 | // 17 | // NOTE: Experimental 18 | func NewCompletionHTTPRequest(ctx context.Context, url string, completion OperationCompletion) (*http.Request, error) { 19 | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, nil) 20 | if err != nil { 21 | return nil, err 22 | } 23 | if err := completion.applyToHTTPRequest(httpReq); err != nil { 24 | return nil, err 25 | } 26 | 27 | httpReq.Header.Set(headerUserAgent, userAgent) 28 | return httpReq, nil 29 | } 30 | 31 | // OperationCompletion is input for [NewCompletionHTTPRequest]. 32 | // It has two implementations: [OperationCompletionSuccessful] and [OperationCompletionUnsuccessful]. 33 | // 34 | // NOTE: Experimental 35 | type OperationCompletion interface { 36 | applyToHTTPRequest(*http.Request) error 37 | } 38 | 39 | // OperationCompletionSuccessful is input for [NewCompletionHTTPRequest], used to deliver successful operation results. 40 | // 41 | // NOTE: Experimental 42 | type OperationCompletionSuccessful struct { 43 | // Header to send in the completion request. 44 | // Note that this is a Nexus header, not an HTTP header. 45 | Header Header 46 | 47 | // A [Reader] that may be directly set on the completion or constructed when instantiating via 48 | // [NewOperationCompletionSuccessful]. 49 | // Automatically closed when the completion is delivered. 50 | Reader *Reader 51 | // OperationID is the unique ID for this operation. Used when a completion callback is received before a started response. 52 | // 53 | // Deprecated: Use OperatonToken instead. 54 | OperationID string 55 | // OperationToken is the unique token for this operation. Used when a completion callback is received before a 56 | // started response. 57 | OperationToken string 58 | // StartTime is the time the operation started. Used when a completion callback is received before a started response. 59 | StartTime time.Time 60 | // Links are used to link back to the operation when a completion callback is received before a started response. 61 | Links []Link 62 | } 63 | 64 | // OperationCompletionSuccessfulOptions are options for [NewOperationCompletionSuccessful]. 65 | // 66 | // NOTE: Experimental 67 | type OperationCompletionSuccessfulOptions struct { 68 | // Optional serializer for the result. Defaults to the SDK's default Serializer, which handles JSONables, byte 69 | // slices and nils. 70 | Serializer Serializer 71 | // OperationID is the unique ID for this operation. Used when a completion callback is received before a started response. 72 | // 73 | // Deprecated: Use OperatonToken instead. 74 | OperationID string 75 | // OperationToken is the unique token for this operation. Used when a completion callback is received before a 76 | // started response. 77 | OperationToken string 78 | // StartTime is the time the operation started. Used when a completion callback is received before a started response. 79 | StartTime time.Time 80 | // Links are used to link back to the operation when a completion callback is received before a started response. 81 | Links []Link 82 | } 83 | 84 | // NewOperationCompletionSuccessful constructs an [OperationCompletionSuccessful] from a given result. 85 | // 86 | // NOTE: Experimental 87 | func NewOperationCompletionSuccessful(result any, options OperationCompletionSuccessfulOptions) (*OperationCompletionSuccessful, error) { 88 | reader, ok := result.(*Reader) 89 | if !ok { 90 | content, ok := result.(*Content) 91 | if !ok { 92 | serializer := options.Serializer 93 | if serializer == nil { 94 | serializer = defaultSerializer 95 | } 96 | var err error 97 | content, err = serializer.Serialize(result) 98 | if err != nil { 99 | return nil, err 100 | } 101 | } 102 | header := maps.Clone(content.Header) 103 | if header == nil { 104 | header = make(Header, 1) 105 | } 106 | header["length"] = strconv.Itoa(len(content.Data)) 107 | 108 | reader = &Reader{ 109 | Header: header, 110 | ReadCloser: io.NopCloser(bytes.NewReader(content.Data)), 111 | } 112 | } 113 | 114 | return &OperationCompletionSuccessful{ 115 | Header: make(Header), 116 | Reader: reader, 117 | OperationID: options.OperationID, 118 | OperationToken: options.OperationToken, 119 | StartTime: options.StartTime, 120 | Links: options.Links, 121 | }, nil 122 | } 123 | 124 | func (c *OperationCompletionSuccessful) applyToHTTPRequest(request *http.Request) error { 125 | if request.Header == nil { 126 | request.Header = make(http.Header, len(c.Header)+len(c.Reader.Header)+1) // +1 for headerOperationState 127 | } 128 | if c.Reader.Header != nil { 129 | addContentHeaderToHTTPHeader(c.Reader.Header, request.Header) 130 | } 131 | if c.Header != nil { 132 | addNexusHeaderToHTTPHeader(c.Header, request.Header) 133 | } 134 | request.Header.Set(headerOperationState, string(OperationStateSucceeded)) 135 | 136 | if c.OperationID == "" && c.OperationToken != "" { 137 | c.OperationID = c.OperationToken 138 | } else if c.OperationToken == "" && c.OperationID != "" { 139 | c.OperationToken = c.OperationID 140 | } 141 | if c.Header.Get(HeaderOperationID) == "" && c.OperationID != "" { 142 | request.Header.Set(HeaderOperationID, c.OperationID) 143 | } 144 | if c.Header.Get(HeaderOperationToken) == "" && c.OperationToken != "" { 145 | request.Header.Set(HeaderOperationToken, c.OperationToken) 146 | } 147 | if c.Header.Get(headerOperationStartTime) == "" && !c.StartTime.IsZero() { 148 | request.Header.Set(headerOperationStartTime, c.StartTime.Format(http.TimeFormat)) 149 | } 150 | if c.Header.Get(headerLink) == "" { 151 | if err := addLinksToHTTPHeader(c.Links, request.Header); err != nil { 152 | return err 153 | } 154 | } 155 | 156 | request.Body = c.Reader.ReadCloser 157 | return nil 158 | } 159 | 160 | // OperationCompletionUnsuccessful is input for [NewCompletionHTTPRequest], used to deliver unsuccessful operation 161 | // results. 162 | // 163 | // NOTE: Experimental 164 | type OperationCompletionUnsuccessful struct { 165 | // Header to send in the completion request. 166 | // Note that this is a Nexus header, not an HTTP header. 167 | Header Header 168 | // State of the operation, should be failed or canceled. 169 | State OperationState 170 | // OperationID is the unique ID for this operation. Used when a completion callback is received before a started response. 171 | // 172 | // Deprecated: Use OperatonToken instead. 173 | OperationID string 174 | // OperationToken is the unique token for this operation. Used when a completion callback is received before a 175 | // started response. 176 | OperationToken string 177 | // StartTime is the time the operation started. Used when a completion callback is received before a started response. 178 | StartTime time.Time 179 | // Links are used to link back to the operation when a completion callback is received before a started response. 180 | Links []Link 181 | // Failure object to send with the completion. 182 | Failure Failure 183 | } 184 | 185 | // OperationCompletionUnsuccessfulOptions are options for [NewOperationCompletionUnsuccessful]. 186 | // 187 | // NOTE: Experimental 188 | type OperationCompletionUnsuccessfulOptions struct { 189 | // A [FailureConverter] to convert a [Failure] instance to and from an [error]. Defaults to 190 | // [DefaultFailureConverter]. 191 | FailureConverter FailureConverter 192 | // OperationID is the unique ID for this operation. Used when a completion callback is received before a started response. 193 | // 194 | // Deprecated: Use OperatonToken instead. 195 | OperationID string 196 | // OperationToken is the unique token for this operation. Used when a completion callback is received before a 197 | // started response. 198 | OperationToken string 199 | // StartTime is the time the operation started. Used when a completion callback is received before a started response. 200 | StartTime time.Time 201 | // Links are used to link back to the operation when a completion callback is received before a started response. 202 | Links []Link 203 | } 204 | 205 | // NewOperationCompletionUnsuccessful constructs an [OperationCompletionUnsuccessful] from a given error. 206 | // 207 | // NOTE: Experimental 208 | func NewOperationCompletionUnsuccessful(error *OperationError, options OperationCompletionUnsuccessfulOptions) (*OperationCompletionUnsuccessful, error) { 209 | if options.FailureConverter == nil { 210 | options.FailureConverter = defaultFailureConverter 211 | } 212 | 213 | return &OperationCompletionUnsuccessful{ 214 | Header: make(Header), 215 | State: error.State, 216 | Failure: options.FailureConverter.ErrorToFailure(error.Cause), 217 | OperationID: options.OperationID, 218 | OperationToken: options.OperationToken, 219 | StartTime: options.StartTime, 220 | Links: options.Links, 221 | }, nil 222 | } 223 | 224 | func (c *OperationCompletionUnsuccessful) applyToHTTPRequest(request *http.Request) error { 225 | if request.Header == nil { 226 | request.Header = make(http.Header, len(c.Header)+2) // +2 for headerOperationState and content-type 227 | } 228 | if c.Header != nil { 229 | addNexusHeaderToHTTPHeader(c.Header, request.Header) 230 | } 231 | request.Header.Set(headerOperationState, string(c.State)) 232 | request.Header.Set("Content-Type", contentTypeJSON) 233 | 234 | if c.OperationID == "" && c.OperationToken != "" { 235 | c.OperationID = c.OperationToken 236 | } 237 | if c.OperationToken == "" && c.OperationID != "" { 238 | c.OperationToken = c.OperationID 239 | } 240 | if c.Header.Get(HeaderOperationID) == "" && c.OperationID != "" { 241 | request.Header.Set(HeaderOperationID, c.OperationID) 242 | } else if c.Header.Get(HeaderOperationToken) == "" && c.OperationToken != "" { 243 | request.Header.Set(HeaderOperationToken, c.OperationToken) 244 | } 245 | if c.Header.Get(headerOperationStartTime) == "" && !c.StartTime.IsZero() { 246 | request.Header.Set(headerOperationStartTime, c.StartTime.Format(http.TimeFormat)) 247 | } 248 | if c.Header.Get(headerLink) == "" { 249 | if err := addLinksToHTTPHeader(c.Links, request.Header); err != nil { 250 | return err 251 | } 252 | } 253 | 254 | b, err := json.Marshal(c.Failure) 255 | if err != nil { 256 | return err 257 | } 258 | 259 | request.Body = io.NopCloser(bytes.NewReader(b)) 260 | return nil 261 | } 262 | 263 | // CompletionRequest is input for CompletionHandler.CompleteOperation. 264 | // 265 | // NOTE: Experimental 266 | type CompletionRequest struct { 267 | // The original HTTP request. 268 | HTTPRequest *http.Request 269 | // State of the operation. 270 | State OperationState 271 | // OperationID is the unique ID for this operation. Used when a completion callback is received before a started response. 272 | // 273 | // Deprecated: Use OperatonToken instead. 274 | OperationID string 275 | // OperationToken is the unique token for this operation. Used when a completion callback is received before a 276 | // started response. 277 | OperationToken string 278 | // StartTime is the time the operation started. Used when a completion callback is received before a started response. 279 | StartTime time.Time 280 | // Links are used to link back to the operation when a completion callback is received before a started response. 281 | Links []Link 282 | // Parsed from request and set if State is failed or canceled. 283 | Error error 284 | // Extracted from request and set if State is succeeded. 285 | Result *LazyValue 286 | } 287 | 288 | // A CompletionHandler can receive operation completion requests as delivered via the callback URL provided in 289 | // start-operation requests. 290 | // 291 | // NOTE: Experimental 292 | type CompletionHandler interface { 293 | CompleteOperation(context.Context, *CompletionRequest) error 294 | } 295 | 296 | // CompletionHandlerOptions are options for [NewCompletionHTTPHandler]. 297 | // 298 | // NOTE: Experimental 299 | type CompletionHandlerOptions struct { 300 | // Handler for completion requests. 301 | Handler CompletionHandler 302 | // A stuctured logging handler. 303 | // Defaults to slog.Default(). 304 | Logger *slog.Logger 305 | // A [Serializer] to customize handler serialization behavior. 306 | // By default the handler handles, JSONables, byte slices, and nil. 307 | Serializer Serializer 308 | // A [FailureConverter] to convert a [Failure] instance to and from an [error]. Defaults to 309 | // [DefaultFailureConverter]. 310 | FailureConverter FailureConverter 311 | } 312 | 313 | type completionHTTPHandler struct { 314 | baseHTTPHandler 315 | options CompletionHandlerOptions 316 | } 317 | 318 | func (h *completionHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { 319 | ctx := request.Context() 320 | completion := CompletionRequest{ 321 | State: OperationState(request.Header.Get(headerOperationState)), 322 | OperationID: request.Header.Get(HeaderOperationID), 323 | OperationToken: request.Header.Get(HeaderOperationToken), 324 | HTTPRequest: request, 325 | } 326 | if completion.OperationID == "" && completion.OperationToken != "" { 327 | completion.OperationID = completion.OperationToken 328 | } else if completion.OperationToken == "" && completion.OperationID != "" { 329 | completion.OperationToken = completion.OperationID 330 | } 331 | if startTimeHeader := request.Header.Get(headerOperationStartTime); startTimeHeader != "" { 332 | var parseTimeErr error 333 | if completion.StartTime, parseTimeErr = http.ParseTime(startTimeHeader); parseTimeErr != nil { 334 | h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "failed to parse operation start time header")) 335 | return 336 | } 337 | } 338 | var decodeErr error 339 | if completion.Links, decodeErr = getLinksFromHeader(request.Header); decodeErr != nil { 340 | h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "failed to decode links from request headers")) 341 | return 342 | } 343 | switch completion.State { 344 | case OperationStateFailed, OperationStateCanceled: 345 | if !isMediaTypeJSON(request.Header.Get("Content-Type")) { 346 | h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid request content type: %q", request.Header.Get("Content-Type"))) 347 | return 348 | } 349 | var failure Failure 350 | b, err := io.ReadAll(request.Body) 351 | if err != nil { 352 | h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "failed to read Failure from request body")) 353 | return 354 | } 355 | if err := json.Unmarshal(b, &failure); err != nil { 356 | h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "failed to read Failure from request body")) 357 | return 358 | } 359 | completion.Error = h.failureConverter.FailureToError(failure) 360 | case OperationStateSucceeded: 361 | completion.Result = &LazyValue{ 362 | serializer: h.options.Serializer, 363 | Reader: &Reader{ 364 | request.Body, 365 | prefixStrippedHTTPHeaderToNexusHeader(request.Header, "content-"), 366 | }, 367 | } 368 | default: 369 | h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid request operation state: %q", completion.State)) 370 | return 371 | } 372 | if err := h.options.Handler.CompleteOperation(ctx, &completion); err != nil { 373 | h.writeFailure(writer, err) 374 | } 375 | } 376 | 377 | // NewCompletionHTTPHandler constructs an [http.Handler] from given options for handling operation completion requests. 378 | // 379 | // NOTE: Experimental 380 | func NewCompletionHTTPHandler(options CompletionHandlerOptions) http.Handler { 381 | if options.Logger == nil { 382 | options.Logger = slog.Default() 383 | } 384 | if options.Serializer == nil { 385 | options.Serializer = defaultSerializer 386 | } 387 | if options.FailureConverter == nil { 388 | options.FailureConverter = defaultFailureConverter 389 | } 390 | return &completionHTTPHandler{ 391 | options: options, 392 | baseHTTPHandler: baseHTTPHandler{ 393 | logger: options.Logger, 394 | failureConverter: options.FailureConverter, 395 | }, 396 | } 397 | } 398 | -------------------------------------------------------------------------------- /nexus/completion_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | type successfulCompletionHandler struct { 16 | } 17 | 18 | func (h *successfulCompletionHandler) CompleteOperation(ctx context.Context, completion *CompletionRequest) error { 19 | if completion.HTTPRequest.URL.Path != "/callback" { 20 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid URL path: %q", completion.HTTPRequest.URL.Path) 21 | } 22 | if completion.HTTPRequest.URL.Query().Get("a") != "b" { 23 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'a' query param: %q", completion.HTTPRequest.URL.Query().Get("a")) 24 | } 25 | if completion.HTTPRequest.Header.Get("foo") != "bar" { 26 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'foo' header: %q", completion.HTTPRequest.Header.Get("foo")) 27 | } 28 | if completion.HTTPRequest.Header.Get("User-Agent") != userAgent { 29 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'User-Agent' header: %q", completion.HTTPRequest.Header.Get("User-Agent")) 30 | } 31 | if completion.OperationID != "test-operation-token" { 32 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid operation ID: %q", completion.OperationID) 33 | } 34 | if completion.OperationToken != "test-operation-token" { 35 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid operation token: %q", completion.OperationToken) 36 | } 37 | if len(completion.Links) == 0 { 38 | return HandlerErrorf(HandlerErrorTypeBadRequest, "expected Links to be set on CompletionRequest") 39 | } 40 | var result int 41 | err := completion.Result.Consume(&result) 42 | if err != nil { 43 | return err 44 | } 45 | if result != 666 { 46 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid result: %q", result) 47 | } 48 | return nil 49 | } 50 | 51 | func TestSuccessfulCompletion(t *testing.T) { 52 | ctx, callbackURL, teardown := setupForCompletion(t, &successfulCompletionHandler{}, nil, nil) 53 | defer teardown() 54 | 55 | completion, err := NewOperationCompletionSuccessful(666, OperationCompletionSuccessfulOptions{ 56 | OperationToken: "test-operation-token", 57 | StartTime: time.Now(), 58 | Links: []Link{{ 59 | URL: &url.URL{ 60 | Scheme: "https", 61 | Host: "example.com", 62 | Path: "/path/to/something", 63 | RawQuery: "param=value", 64 | }, 65 | Type: "url", 66 | }}, 67 | }) 68 | completion.Header.Set("foo", "bar") 69 | require.NoError(t, err) 70 | 71 | request, err := NewCompletionHTTPRequest(ctx, callbackURL, completion) 72 | require.NoError(t, err) 73 | response, err := http.DefaultClient.Do(request) 74 | require.NoError(t, err) 75 | defer response.Body.Close() 76 | _, err = io.ReadAll(response.Body) 77 | require.NoError(t, err) 78 | require.Equal(t, http.StatusOK, response.StatusCode) 79 | } 80 | 81 | func TestSuccessfulCompletion_CustomSerializer(t *testing.T) { 82 | serializer := &customSerializer{} 83 | ctx, callbackURL, teardown := setupForCompletion(t, &successfulCompletionHandler{}, serializer, nil) 84 | defer teardown() 85 | 86 | completion, err := NewOperationCompletionSuccessful(666, OperationCompletionSuccessfulOptions{ 87 | Serializer: serializer, 88 | Links: []Link{{ 89 | URL: &url.URL{ 90 | Scheme: "https", 91 | Host: "example.com", 92 | Path: "/path/to/something", 93 | RawQuery: "param=value", 94 | }, 95 | Type: "url", 96 | }}, 97 | }) 98 | completion.Header.Set("foo", "bar") 99 | completion.Header.Set(HeaderOperationToken, "test-operation-token") 100 | require.NoError(t, err) 101 | 102 | request, err := NewCompletionHTTPRequest(ctx, callbackURL, completion) 103 | require.NoError(t, err) 104 | response, err := http.DefaultClient.Do(request) 105 | require.NoError(t, err) 106 | defer response.Body.Close() 107 | _, err = io.ReadAll(response.Body) 108 | require.NoError(t, err) 109 | require.Equal(t, http.StatusOK, response.StatusCode) 110 | 111 | require.Equal(t, 1, serializer.decoded) 112 | require.Equal(t, 1, serializer.encoded) 113 | } 114 | 115 | type failureExpectingCompletionHandler struct { 116 | errorChecker func(error) error 117 | } 118 | 119 | func (h *failureExpectingCompletionHandler) CompleteOperation(ctx context.Context, completion *CompletionRequest) error { 120 | if completion.State != OperationStateCanceled { 121 | return HandlerErrorf(HandlerErrorTypeBadRequest, "unexpected completion state: %q", completion.State) 122 | } 123 | if err := h.errorChecker(completion.Error); err != nil { 124 | return err 125 | } 126 | if completion.HTTPRequest.Header.Get("foo") != "bar" { 127 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'foo' header: %q", completion.HTTPRequest.Header.Get("foo")) 128 | } 129 | if completion.OperationID != "test-operation-token" { 130 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid operation ID: %q", completion.OperationID) 131 | } 132 | if completion.OperationToken != "test-operation-token" { 133 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid operation token: %q", completion.OperationToken) 134 | } 135 | if len(completion.Links) == 0 { 136 | return HandlerErrorf(HandlerErrorTypeBadRequest, "expected Links to be set on CompletionRequest") 137 | } 138 | 139 | return nil 140 | } 141 | 142 | func TestFailureCompletion(t *testing.T) { 143 | ctx, callbackURL, teardown := setupForCompletion(t, &failureExpectingCompletionHandler{ 144 | errorChecker: func(err error) error { 145 | if err.Error() != "expected message" { 146 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid failure: %v", err) 147 | } 148 | return nil 149 | }, 150 | }, nil, nil) 151 | defer teardown() 152 | 153 | completion, err := NewOperationCompletionUnsuccessful(NewCanceledOperationError(errors.New("expected message")), OperationCompletionUnsuccessfulOptions{ 154 | OperationToken: "test-operation-token", 155 | StartTime: time.Now(), 156 | Links: []Link{{ 157 | URL: &url.URL{ 158 | Scheme: "https", 159 | Host: "example.com", 160 | Path: "/path/to/something", 161 | RawQuery: "param=value", 162 | }, 163 | Type: "url", 164 | }}, 165 | }) 166 | require.NoError(t, err) 167 | completion.Header.Set("foo", "bar") 168 | request, err := NewCompletionHTTPRequest(ctx, callbackURL, completion) 169 | require.NoError(t, err) 170 | response, err := http.DefaultClient.Do(request) 171 | require.NoError(t, err) 172 | defer response.Body.Close() 173 | _, err = io.ReadAll(response.Body) 174 | require.NoError(t, err) 175 | require.Equal(t, http.StatusOK, response.StatusCode) 176 | } 177 | 178 | func TestFailureCompletion_CustomFailureConverter(t *testing.T) { 179 | fc := customFailureConverter{} 180 | ctx, callbackURL, teardown := setupForCompletion(t, &failureExpectingCompletionHandler{ 181 | errorChecker: func(err error) error { 182 | if !errors.Is(err, errCustom) { 183 | return HandlerErrorf(HandlerErrorTypeBadRequest, "invalid failure, expected a custom error: %v", err) 184 | } 185 | return nil 186 | }, 187 | }, nil, fc) 188 | defer teardown() 189 | 190 | completion, err := NewOperationCompletionUnsuccessful(NewCanceledOperationError(errors.New("expected message")), OperationCompletionUnsuccessfulOptions{ 191 | FailureConverter: fc, 192 | OperationToken: "test-operation-token", 193 | StartTime: time.Now(), 194 | Links: []Link{{ 195 | URL: &url.URL{ 196 | Scheme: "https", 197 | Host: "example.com", 198 | Path: "/path/to/something", 199 | RawQuery: "param=value", 200 | }, 201 | Type: "url", 202 | }}, 203 | }) 204 | require.NoError(t, err) 205 | completion.Header.Set("foo", "bar") 206 | request, err := NewCompletionHTTPRequest(ctx, callbackURL, completion) 207 | require.NoError(t, err) 208 | response, err := http.DefaultClient.Do(request) 209 | require.NoError(t, err) 210 | defer response.Body.Close() 211 | _, err = io.ReadAll(response.Body) 212 | require.NoError(t, err) 213 | require.Equal(t, http.StatusOK, response.StatusCode) 214 | } 215 | 216 | type failingCompletionHandler struct { 217 | } 218 | 219 | func (h *failingCompletionHandler) CompleteOperation(ctx context.Context, completion *CompletionRequest) error { 220 | return HandlerErrorf(HandlerErrorTypeBadRequest, "I can't get no satisfaction") 221 | } 222 | 223 | func TestBadRequestCompletion(t *testing.T) { 224 | ctx, callbackURL, teardown := setupForCompletion(t, &failingCompletionHandler{}, nil, nil) 225 | defer teardown() 226 | 227 | completion, err := NewOperationCompletionSuccessful([]byte("success"), OperationCompletionSuccessfulOptions{}) 228 | require.NoError(t, err) 229 | request, err := NewCompletionHTTPRequest(ctx, callbackURL, completion) 230 | require.NoError(t, err) 231 | response, err := http.DefaultClient.Do(request) 232 | require.NoError(t, err) 233 | defer response.Body.Close() 234 | _, err = io.ReadAll(response.Body) 235 | require.NoError(t, err) 236 | require.Equal(t, http.StatusBadRequest, response.StatusCode) 237 | } 238 | -------------------------------------------------------------------------------- /nexus/get_info_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type asyncWithInfoHandler struct { 12 | UnimplementedHandler 13 | expectHeader bool 14 | } 15 | 16 | func (h *asyncWithInfoHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 17 | return &HandlerStartOperationResultAsync{ 18 | OperationToken: "just-a-token", 19 | }, nil 20 | } 21 | 22 | func (h *asyncWithInfoHandler) GetOperationInfo(ctx context.Context, service, operation, token string, options GetOperationInfoOptions) (*OperationInfo, error) { 23 | if service != testService { 24 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "unexpected service: %s", service) 25 | } 26 | if operation != "escape/me" { 27 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "expected operation to be 'escape me', got: %s", operation) 28 | } 29 | if token != "just-a-token" { 30 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "expected operation token to be 'just-a-token', got: %s", token) 31 | } 32 | if h.expectHeader && options.Header.Get("test") != "ok" { 33 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'test' request header") 34 | } 35 | if options.Header.Get("User-Agent") != userAgent { 36 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'User-Agent' header: %q", options.Header.Get("User-Agent")) 37 | } 38 | return &OperationInfo{ 39 | ID: token, 40 | State: OperationStateCanceled, 41 | }, nil 42 | } 43 | 44 | func TestGetHandlerFromStartInfoHeader(t *testing.T) { 45 | ctx, client, teardown := setup(t, &asyncWithInfoHandler{expectHeader: true}) 46 | defer teardown() 47 | 48 | result, err := client.StartOperation(ctx, "escape/me", nil, StartOperationOptions{}) 49 | require.NoError(t, err) 50 | handle := result.Pending 51 | require.NotNil(t, handle) 52 | info, err := handle.GetInfo(ctx, GetOperationInfoOptions{ 53 | Header: Header{"test": "ok"}, 54 | }) 55 | require.NoError(t, err) 56 | require.Equal(t, handle.ID, info.ID) 57 | require.Equal(t, OperationStateCanceled, info.State) 58 | } 59 | 60 | func TestGetInfoHandleFromClientNoHeader(t *testing.T) { 61 | ctx, client, teardown := setup(t, &asyncWithInfoHandler{}) 62 | defer teardown() 63 | 64 | handle, err := client.NewHandle("escape/me", "just-a-token") 65 | require.NoError(t, err) 66 | info, err := handle.GetInfo(ctx, GetOperationInfoOptions{}) 67 | require.NoError(t, err) 68 | require.Equal(t, handle.ID, info.ID) 69 | require.Equal(t, OperationStateCanceled, info.State) 70 | } 71 | 72 | type asyncWithInfoTimeoutHandler struct { 73 | expectedTimeout time.Duration 74 | UnimplementedHandler 75 | } 76 | 77 | func (h *asyncWithInfoTimeoutHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 78 | return &HandlerStartOperationResultAsync{ 79 | OperationToken: "timeout", 80 | }, nil 81 | } 82 | 83 | func (h *asyncWithInfoTimeoutHandler) GetOperationInfo(ctx context.Context, service, operation, token string, options GetOperationInfoOptions) (*OperationInfo, error) { 84 | deadline, set := ctx.Deadline() 85 | if h.expectedTimeout > 0 && !set { 86 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "expected operation to have timeout set but context has no deadline") 87 | } 88 | if h.expectedTimeout <= 0 && set { 89 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "expected operation to have no timeout but context has deadline set") 90 | } 91 | timeout := time.Until(deadline) 92 | if timeout > h.expectedTimeout { 93 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "operation has timeout (%s) greater than expected (%s)", timeout.String(), h.expectedTimeout.String()) 94 | } 95 | 96 | return &OperationInfo{ 97 | ID: token, 98 | State: OperationStateCanceled, 99 | }, nil 100 | } 101 | 102 | func TestGetInfo_ContextDeadlinePropagated(t *testing.T) { 103 | ctx, client, teardown := setup(t, &asyncWithInfoTimeoutHandler{expectedTimeout: testTimeout}) 104 | defer teardown() 105 | 106 | handle, err := client.NewHandle("foo", "timeout") 107 | require.NoError(t, err) 108 | _, err = handle.GetInfo(ctx, GetOperationInfoOptions{}) 109 | require.NoError(t, err) 110 | } 111 | 112 | func TestGetInfo_RequestTimeoutHeaderOverridesContextDeadline(t *testing.T) { 113 | timeout := 100 * time.Millisecond 114 | // relies on ctx returned here having default testTimeout set greater than expected timeout 115 | ctx, client, teardown := setup(t, &asyncWithInfoTimeoutHandler{expectedTimeout: timeout}) 116 | defer teardown() 117 | 118 | handle, err := client.NewHandle("foo", "timeout") 119 | require.NoError(t, err) 120 | _, err = handle.GetInfo(ctx, GetOperationInfoOptions{Header: Header{HeaderRequestTimeout: formatDuration(timeout)}}) 121 | require.NoError(t, err) 122 | } 123 | 124 | func TestGetInfo_TimeoutNotPropagated(t *testing.T) { 125 | _, client, teardown := setup(t, &asyncWithInfoTimeoutHandler{}) 126 | defer teardown() 127 | 128 | handle, err := client.NewHandle("foo", "timeout") 129 | require.NoError(t, err) 130 | _, err = handle.GetInfo(context.Background(), GetOperationInfoOptions{}) 131 | require.NoError(t, err) 132 | } 133 | -------------------------------------------------------------------------------- /nexus/get_result_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | type request struct { 12 | options GetOperationResultOptions 13 | operation string 14 | token string 15 | deadline time.Time 16 | } 17 | 18 | type asyncWithResultHandler struct { 19 | UnimplementedHandler 20 | timesToBlock int 21 | resultError error 22 | expectTestHeader bool 23 | requests []request 24 | } 25 | 26 | func (h *asyncWithResultHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 27 | if h.expectTestHeader && options.Header.Get("test") != "ok" { 28 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'test' header: %q", options.Header.Get("test")) 29 | } 30 | 31 | return &HandlerStartOperationResultAsync{ 32 | OperationToken: "async", 33 | }, nil 34 | } 35 | 36 | func (h *asyncWithResultHandler) getResult() (any, error) { 37 | if h.resultError != nil { 38 | return nil, h.resultError 39 | } 40 | return []byte("body"), nil 41 | } 42 | 43 | func (h *asyncWithResultHandler) GetOperationResult(ctx context.Context, service, operation, token string, options GetOperationResultOptions) (any, error) { 44 | req := request{options: options, operation: operation, token: token} 45 | deadline, set := ctx.Deadline() 46 | if set { 47 | req.deadline = deadline 48 | } 49 | h.requests = append(h.requests, req) 50 | 51 | if service != testService { 52 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "unexpected service: %s", service) 53 | } 54 | if h.expectTestHeader && options.Header.Get("test") != "ok" { 55 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'test' header: %q", options.Header.Get("test")) 56 | } 57 | if options.Header.Get("User-Agent") != userAgent { 58 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'User-Agent' header: %q", options.Header.Get("User-Agent")) 59 | } 60 | if options.Header.Get("Content-Type") != "" { 61 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "'Content-Type' header set on request") 62 | } 63 | if options.Wait == 0 { 64 | return h.getResult() 65 | } 66 | if options.Wait > 0 { 67 | deadline, set := ctx.Deadline() 68 | if !set { 69 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "context deadline unset") 70 | } 71 | timeout := time.Until(deadline) 72 | diff := (getResultMaxTimeout - timeout).Abs() 73 | if diff > time.Millisecond*200 { 74 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "context deadline invalid, timeout: %v", timeout) 75 | } 76 | } 77 | if len(h.requests) <= h.timesToBlock { 78 | ctx, cancel := context.WithTimeout(ctx, options.Wait) 79 | defer cancel() 80 | <-ctx.Done() 81 | return nil, ErrOperationStillRunning 82 | } 83 | return h.getResult() 84 | } 85 | 86 | func TestWaitResult(t *testing.T) { 87 | handler := asyncWithResultHandler{timesToBlock: 1, expectTestHeader: true} 88 | ctx, client, teardown := setup(t, &handler) 89 | defer teardown() 90 | 91 | response, err := client.ExecuteOperation(ctx, "f/o/o", nil, ExecuteOperationOptions{ 92 | Header: Header{"test": "ok"}, 93 | }) 94 | require.NoError(t, err) 95 | var body []byte 96 | err = response.Consume(&body) 97 | require.NoError(t, err) 98 | require.Equal(t, []byte("body"), body) 99 | 100 | require.Equal(t, 2, len(handler.requests)) 101 | require.InDelta(t, testTimeout+getResultContextPadding, handler.requests[0].options.Wait, float64(time.Millisecond*50)) 102 | require.InDelta(t, testTimeout+getResultContextPadding-getResultMaxTimeout, handler.requests[1].options.Wait, float64(time.Millisecond*50)) 103 | require.Equal(t, "f/o/o", handler.requests[0].operation) 104 | require.Equal(t, "async", handler.requests[0].token) 105 | } 106 | 107 | func TestWaitResult_StillRunning(t *testing.T) { 108 | ctx, client, teardown := setup(t, &asyncWithResultHandler{timesToBlock: 1000}) 109 | defer teardown() 110 | 111 | result, err := client.StartOperation(ctx, "foo", nil, StartOperationOptions{}) 112 | require.NoError(t, err) 113 | handle := result.Pending 114 | require.NotNil(t, handle) 115 | 116 | ctx = context.Background() 117 | _, err = handle.GetResult(ctx, GetOperationResultOptions{Wait: time.Millisecond * 200}) 118 | require.ErrorIs(t, err, ErrOperationStillRunning) 119 | } 120 | 121 | func TestWaitResult_DeadlineExceeded(t *testing.T) { 122 | handler := &asyncWithResultHandler{timesToBlock: 1000} 123 | ctx, client, teardown := setup(t, handler) 124 | defer teardown() 125 | 126 | result, err := client.StartOperation(ctx, "foo", nil, StartOperationOptions{}) 127 | require.NoError(t, err) 128 | handle := result.Pending 129 | require.NotNil(t, handle) 130 | 131 | ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*200) 132 | defer cancel() 133 | deadline, _ := ctx.Deadline() 134 | _, err = handle.GetResult(ctx, GetOperationResultOptions{Wait: time.Second}) 135 | require.ErrorIs(t, err, context.DeadlineExceeded) 136 | // Allow up to 10 ms delay to account for slow CI. 137 | // This test is inherently flaky, and should be rewritten. 138 | require.WithinDuration(t, deadline, handler.requests[0].deadline, 10*time.Millisecond) 139 | } 140 | 141 | func TestWaitResult_RequestTimeout(t *testing.T) { 142 | handler := &asyncWithResultHandler{timesToBlock: 1000} 143 | ctx, client, teardown := setup(t, handler) 144 | defer teardown() 145 | 146 | result, err := client.StartOperation(ctx, "foo", nil, StartOperationOptions{}) 147 | require.NoError(t, err) 148 | handle := result.Pending 149 | require.NotNil(t, handle) 150 | 151 | timeout := 200 * time.Millisecond 152 | deadline := time.Now().Add(timeout) 153 | _, err = handle.GetResult(ctx, GetOperationResultOptions{Wait: time.Second, Header: Header{HeaderRequestTimeout: formatDuration(timeout)}}) 154 | require.ErrorIs(t, err, ErrOperationStillRunning) 155 | require.WithinDuration(t, deadline, handler.requests[0].deadline, 1*time.Millisecond) 156 | } 157 | 158 | func TestPeekResult_StillRunning(t *testing.T) { 159 | handler := asyncWithResultHandler{resultError: ErrOperationStillRunning} 160 | ctx, client, teardown := setup(t, &handler) 161 | defer teardown() 162 | 163 | handle, err := client.NewHandle("foo", "a/sync") 164 | require.NoError(t, err) 165 | response, err := handle.GetResult(ctx, GetOperationResultOptions{}) 166 | require.ErrorIs(t, err, ErrOperationStillRunning) 167 | require.Nil(t, response) 168 | require.Equal(t, 1, len(handler.requests)) 169 | require.Equal(t, time.Duration(0), handler.requests[0].options.Wait) 170 | } 171 | 172 | func TestPeekResult_Success(t *testing.T) { 173 | ctx, client, teardown := setup(t, &asyncWithResultHandler{}) 174 | defer teardown() 175 | 176 | handle, err := client.NewHandle("foo", "a/sync") 177 | require.NoError(t, err) 178 | response, err := handle.GetResult(ctx, GetOperationResultOptions{}) 179 | require.NoError(t, err) 180 | var body []byte 181 | err = response.Consume(&body) 182 | require.NoError(t, err) 183 | require.Equal(t, []byte("body"), body) 184 | } 185 | 186 | func TestPeekResult_Canceled(t *testing.T) { 187 | ctx, client, teardown := setup(t, &asyncWithResultHandler{resultError: &OperationError{State: OperationStateCanceled}}) 188 | defer teardown() 189 | 190 | handle, err := client.NewHandle("foo", "a/sync") 191 | require.NoError(t, err) 192 | _, err = handle.GetResult(ctx, GetOperationResultOptions{}) 193 | var OperationError *OperationError 194 | require.ErrorAs(t, err, &OperationError) 195 | require.Equal(t, OperationStateCanceled, OperationError.State) 196 | } 197 | -------------------------------------------------------------------------------- /nexus/handle.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | ) 10 | 11 | const getResultContextPadding = time.Second * 5 12 | 13 | // An OperationHandle is used to cancel operations and get their result and status. 14 | type OperationHandle[T any] struct { 15 | // Name of the Operation this handle represents. 16 | Operation string 17 | // Handler generated ID for this handle's operation. 18 | // 19 | // Deprecated: Use Token instead. 20 | ID string 21 | // Handler generated token for this handle's operation. 22 | Token string 23 | 24 | client *HTTPClient 25 | } 26 | 27 | // GetInfo gets operation information, issuing a network request to the service handler. 28 | // 29 | // NOTE: Experimental 30 | func (h *OperationHandle[T]) GetInfo(ctx context.Context, options GetOperationInfoOptions) (*OperationInfo, error) { 31 | var u *url.URL 32 | if h.client.options.UseOperationID { 33 | u = h.client.serviceBaseURL.JoinPath(url.PathEscape(h.client.options.Service), url.PathEscape(h.Operation), url.PathEscape(h.ID)) 34 | } else { 35 | u = h.client.serviceBaseURL.JoinPath(url.PathEscape(h.client.options.Service), url.PathEscape(h.Operation)) 36 | } 37 | request, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) 38 | if err != nil { 39 | return nil, err 40 | } 41 | if !h.client.options.UseOperationID { 42 | request.Header.Set(HeaderOperationToken, h.Token) 43 | } 44 | addContextTimeoutToHTTPHeader(ctx, request.Header) 45 | addNexusHeaderToHTTPHeader(options.Header, request.Header) 46 | 47 | request.Header.Set(headerUserAgent, userAgent) 48 | response, err := h.client.options.HTTPCaller(request) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | // Do this once here and make sure it doesn't leak. 54 | body, err := readAndReplaceBody(response) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | if response.StatusCode != http.StatusOK { 60 | return nil, h.client.bestEffortHandlerErrorFromResponse(response, body) 61 | } 62 | 63 | return operationInfoFromResponse(response, body) 64 | } 65 | 66 | // GetResult gets the result of an operation, issuing a network request to the service handler. 67 | // 68 | // By default, GetResult returns (nil, [ErrOperationStillRunning]) immediately after issuing a call if the operation has 69 | // not yet completed. 70 | // 71 | // Callers may set GetOperationResultOptions.Wait to a value greater than 0 to alter this behavior, causing the client 72 | // to long poll for the result issuing one or more requests until the provided wait period exceeds, in which case (nil, 73 | // [ErrOperationStillRunning]) is returned. 74 | // 75 | // The wait time is capped to the deadline of the provided context. Make sure to handle both context deadline errors and 76 | // [ErrOperationStillRunning]. 77 | // 78 | // Note that the wait period is enforced by the server and may not be respected if the server is misbehaving. Set the 79 | // context deadline to the max allowed wait period to ensure this call returns in a timely fashion. 80 | // 81 | // ⚠️ If a [LazyValue] is returned (as indicated by T), it must be consumed to free up the underlying connection. 82 | // 83 | // NOTE: Experimental 84 | func (h *OperationHandle[T]) GetResult(ctx context.Context, options GetOperationResultOptions) (T, error) { 85 | var result T 86 | var u *url.URL 87 | if h.client.options.UseOperationID { 88 | u = h.client.serviceBaseURL.JoinPath(url.PathEscape(h.client.options.Service), url.PathEscape(h.Operation), url.PathEscape(h.ID), "result") 89 | } else { 90 | u = h.client.serviceBaseURL.JoinPath(url.PathEscape(h.client.options.Service), url.PathEscape(h.Operation), "result") 91 | } 92 | request, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) 93 | if err != nil { 94 | return result, err 95 | } 96 | if !h.client.options.UseOperationID { 97 | request.Header.Set(HeaderOperationToken, h.Token) 98 | } 99 | addContextTimeoutToHTTPHeader(ctx, request.Header) 100 | request.Header.Set(headerUserAgent, userAgent) 101 | addNexusHeaderToHTTPHeader(options.Header, request.Header) 102 | 103 | startTime := time.Now() 104 | wait := options.Wait 105 | for { 106 | if wait > 0 { 107 | if deadline, set := ctx.Deadline(); set { 108 | // Ensure we don't wait longer than the deadline but give some buffer to prevent racing between wait and 109 | // context deadline. 110 | wait = min(wait, time.Until(deadline)+getResultContextPadding) 111 | } 112 | 113 | q := request.URL.Query() 114 | q.Set(queryWait, formatDuration(wait)) 115 | request.URL.RawQuery = q.Encode() 116 | } else { 117 | // We may reuse the request object multiple times and will need to reset the query when wait becomes 0 or 118 | // negative. 119 | request.URL.RawQuery = "" 120 | } 121 | 122 | response, err := h.sendGetOperationResultRequest(request) 123 | if err != nil { 124 | if wait > 0 && errors.Is(err, errOperationWaitTimeout) { 125 | // TODO: Backoff a bit in case the server is continually returning timeouts due to some LB configuration 126 | // issue to avoid blowing it up with repeated calls. 127 | wait = options.Wait - time.Since(startTime) 128 | continue 129 | } 130 | return result, err 131 | } 132 | s := &LazyValue{ 133 | serializer: h.client.options.Serializer, 134 | Reader: &Reader{ 135 | response.Body, 136 | prefixStrippedHTTPHeaderToNexusHeader(response.Header, "content-"), 137 | }, 138 | } 139 | if _, ok := any(result).(*LazyValue); ok { 140 | return any(s).(T), nil 141 | } else { 142 | return result, s.Consume(&result) 143 | } 144 | } 145 | } 146 | 147 | func (h *OperationHandle[T]) sendGetOperationResultRequest(request *http.Request) (*http.Response, error) { 148 | response, err := h.client.options.HTTPCaller(request) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | if response.StatusCode == http.StatusOK { 154 | return response, nil 155 | } 156 | 157 | // Do this once here and make sure it doesn't leak. 158 | body, err := readAndReplaceBody(response) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | switch response.StatusCode { 164 | case http.StatusRequestTimeout: 165 | return nil, errOperationWaitTimeout 166 | case statusOperationRunning: 167 | return nil, ErrOperationStillRunning 168 | case statusOperationFailed: 169 | state, err := getUnsuccessfulStateFromHeader(response, body) 170 | if err != nil { 171 | return nil, err 172 | } 173 | failure, err := h.client.failureFromResponse(response, body) 174 | if err != nil { 175 | return nil, err 176 | } 177 | failureErr := h.client.options.FailureConverter.FailureToError(failure) 178 | return nil, &OperationError{ 179 | State: state, 180 | Cause: failureErr, 181 | } 182 | default: 183 | return nil, h.client.bestEffortHandlerErrorFromResponse(response, body) 184 | } 185 | } 186 | 187 | // Cancel requests to cancel an asynchronous operation. 188 | // 189 | // Cancelation is asynchronous and may be not be respected by the operation's implementation. 190 | func (h *OperationHandle[T]) Cancel(ctx context.Context, options CancelOperationOptions) error { 191 | var u *url.URL 192 | if h.client.options.UseOperationID { 193 | u = h.client.serviceBaseURL.JoinPath(url.PathEscape(h.client.options.Service), url.PathEscape(h.Operation), url.PathEscape(h.ID), "cancel") 194 | } else { 195 | u = h.client.serviceBaseURL.JoinPath(url.PathEscape(h.client.options.Service), url.PathEscape(h.Operation), "cancel") 196 | } 197 | request, err := http.NewRequestWithContext(ctx, "POST", u.String(), nil) 198 | if err != nil { 199 | return err 200 | } 201 | 202 | if !h.client.options.UseOperationID { 203 | request.Header.Set(HeaderOperationToken, h.Token) 204 | } 205 | 206 | addContextTimeoutToHTTPHeader(ctx, request.Header) 207 | request.Header.Set(headerUserAgent, userAgent) 208 | addNexusHeaderToHTTPHeader(options.Header, request.Header) 209 | response, err := h.client.options.HTTPCaller(request) 210 | if err != nil { 211 | return err 212 | } 213 | 214 | // Do this once here and make sure it doesn't leak. 215 | body, err := readAndReplaceBody(response) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | if response.StatusCode != http.StatusAccepted { 221 | return h.client.bestEffortHandlerErrorFromResponse(response, body) 222 | } 223 | return nil 224 | } 225 | -------------------------------------------------------------------------------- /nexus/handle_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestNewHandleFailureConditions(t *testing.T) { 10 | client, err := NewHTTPClient(HTTPClientOptions{BaseURL: "http://foo.com", Service: "test"}) 11 | require.NoError(t, err) 12 | _, err = client.NewHandle("", "token") 13 | require.ErrorIs(t, err, errEmptyOperationName) 14 | _, err = client.NewHandle("name", "") 15 | require.ErrorIs(t, err, errEmptyOperationToken) 16 | _, err = client.NewHandle("", "") 17 | require.ErrorIs(t, err, errEmptyOperationName) 18 | require.ErrorIs(t, err, errEmptyOperationToken) 19 | } 20 | -------------------------------------------------------------------------------- /nexus/handler_context_test.go: -------------------------------------------------------------------------------- 1 | package nexus_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/nexus-rpc/sdk-go/nexus" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestHandlerContext(t *testing.T) { 12 | ctx := nexus.WithHandlerContext(context.Background(), nexus.HandlerInfo{Operation: "test"}) 13 | require.True(t, nexus.IsHandlerContext(ctx)) 14 | initial := []nexus.Link{{Type: "foo"}, {Type: "bar"}} 15 | nexus.AddHandlerLinks(ctx, initial...) 16 | additional := nexus.Link{Type: "baz"} 17 | nexus.AddHandlerLinks(ctx, additional) 18 | require.Equal(t, append(initial, additional), nexus.HandlerLinks(ctx)) 19 | nexus.SetHandlerLinks(ctx, initial...) 20 | require.Equal(t, initial, nexus.HandlerLinks(ctx)) 21 | require.Equal(t, nexus.HandlerInfo{Operation: "test"}, nexus.ExtractHandlerInfo(ctx)) 22 | } 23 | -------------------------------------------------------------------------------- /nexus/handler_example_test.go: -------------------------------------------------------------------------------- 1 | package nexus_test 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/nexus-rpc/sdk-go/nexus" 10 | ) 11 | 12 | type myHandler struct { 13 | nexus.UnimplementedHandler 14 | } 15 | 16 | type MyResult struct { 17 | Field string `json:"field"` 18 | } 19 | 20 | // StartOperation implements the Handler interface. 21 | func (h *myHandler) StartOperation(ctx context.Context, service, operation string, input *nexus.LazyValue, options nexus.StartOperationOptions) (nexus.HandlerStartOperationResult[any], error) { 22 | if err := h.authorize(ctx, options.Header); err != nil { 23 | return nil, err 24 | } 25 | return &nexus.HandlerStartOperationResultAsync{OperationToken: "some-token"}, nil 26 | } 27 | 28 | // GetOperationResult implements the Handler interface. 29 | func (h *myHandler) GetOperationResult(ctx context.Context, service, operation, token string, options nexus.GetOperationResultOptions) (any, error) { 30 | if err := h.authorize(ctx, options.Header); err != nil { 31 | return nil, err 32 | } 33 | if options.Wait > 0 { // request is a long poll 34 | var cancel context.CancelFunc 35 | ctx, cancel = context.WithTimeout(ctx, options.Wait) 36 | defer cancel() 37 | 38 | result, err := h.pollOperation(ctx, options.Wait) 39 | if err != nil { 40 | // Translate deadline exceeded to "OperationStillRunning", this may or may not be semantically correct for 41 | // your application. 42 | // Some applications may want to "peek" the current status instead of performing this blind conversion if 43 | // the wait time is exceeded and the request's context deadline has not yet exceeded. 44 | if ctx.Err() != nil { 45 | return nil, nexus.ErrOperationStillRunning 46 | } 47 | // Optionally translate to operation failure (could also result in canceled state). 48 | // Optionally expose the error details to the caller. 49 | return nil, &nexus.OperationError{ 50 | State: nexus.OperationStateFailed, 51 | Cause: err, 52 | } 53 | } 54 | return result, nil 55 | } else { 56 | result, err := h.peekOperation(ctx) 57 | if err != nil { 58 | // Optionally translate to operation failure (could also result in canceled state). 59 | return nil, &nexus.OperationError{ 60 | State: nexus.OperationStateFailed, 61 | Cause: err, 62 | } 63 | } 64 | return result, nil 65 | } 66 | } 67 | 68 | func (h *myHandler) CancelOperation(ctx context.Context, service, operation, token string, options nexus.CancelOperationOptions) error { 69 | // Handlers must implement this. 70 | panic("unimplemented") 71 | } 72 | 73 | func (h *myHandler) GetOperationInfo(ctx context.Context, service, operation, token string, options nexus.GetOperationInfoOptions) (*nexus.OperationInfo, error) { 74 | // Handlers must implement this. 75 | panic("unimplemented") 76 | } 77 | 78 | func (h *myHandler) pollOperation(ctx context.Context, wait time.Duration) (*MyResult, error) { 79 | panic("unimplemented") 80 | } 81 | 82 | func (h *myHandler) peekOperation(ctx context.Context) (*MyResult, error) { 83 | panic("unimplemented") 84 | } 85 | 86 | func (h *myHandler) authorize(_ context.Context, header nexus.Header) error { 87 | // Authorization for demo purposes 88 | if header.Get("Authorization") != "Bearer top-secret" { 89 | return nexus.HandlerErrorf(nexus.HandlerErrorTypeUnauthorized, "unauthorized") 90 | } 91 | return nil 92 | } 93 | 94 | func ExampleHandler() { 95 | handler := &myHandler{} 96 | httpHandler := nexus.NewHTTPHandler(nexus.HandlerOptions{Handler: handler}) 97 | 98 | listener, _ := net.Listen("tcp", "localhost:0") 99 | defer listener.Close() 100 | _ = http.Serve(listener, httpHandler) 101 | } 102 | -------------------------------------------------------------------------------- /nexus/operation.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | "strings" 9 | ) 10 | 11 | // NoValue is a marker type for an operations that do not accept any input or return a value (nil). 12 | // 13 | // nexus.NewSyncOperation("my-empty-operation", func(context.Context, nexus.NoValue, options, nexus.StartOperationOptions) (nexus.NoValue, error) { 14 | // return nil, nil 15 | // )} 16 | type NoValue *struct{} 17 | 18 | // OperationReference provides a typed interface for invoking operations. Every [Operation] is also an 19 | // [OperationReference]. Callers may create references using [NewOperationReference] when the implementation is not 20 | // available. 21 | type OperationReference[I, O any] interface { 22 | Name() string 23 | // InputType the generic input type I for this operation. 24 | InputType() reflect.Type 25 | // OutputType the generic out type O for this operation. 26 | OutputType() reflect.Type 27 | // A type inference helper for implementations of this interface. 28 | inferType(I, O) 29 | } 30 | 31 | type operationReference[I, O any] string 32 | 33 | // NewOperationReference creates an [OperationReference] with the provided type parameters and name. 34 | // It provides typed interface for invoking operations when the implementation is not available to the caller. 35 | func NewOperationReference[I, O any](name string) OperationReference[I, O] { 36 | return operationReference[I, O](name) 37 | } 38 | 39 | func (r operationReference[I, O]) Name() string { 40 | return string(r) 41 | } 42 | 43 | func (operationReference[I, O]) InputType() reflect.Type { 44 | var zero [0]I 45 | return reflect.TypeOf(zero).Elem() 46 | } 47 | 48 | func (operationReference[I, O]) OutputType() reflect.Type { 49 | var zero [0]O 50 | return reflect.TypeOf(zero).Elem() 51 | } 52 | 53 | func (operationReference[I, O]) inferType(I, O) {} //nolint:unused 54 | 55 | // A RegisterableOperation is accepted in [OperationRegistry.Register]. 56 | // Embed [UnimplementedOperation] to implement it. 57 | type RegisterableOperation interface { 58 | // Name of the operation. Used for invocation and registration. 59 | Name() string 60 | mustEmbedUnimplementedOperation() 61 | } 62 | 63 | // Operation is a handler for a single operation. 64 | // 65 | // Operation implementations must embed the [UnimplementedOperation]. 66 | // 67 | // See [OperationHandler] for more information. 68 | type Operation[I, O any] interface { 69 | RegisterableOperation 70 | OperationReference[I, O] 71 | OperationHandler[I, O] 72 | } 73 | 74 | // OperationHandler is the interface for the core operation methods. OperationHandler implementations must embed 75 | // [UnimplementedOperation]. 76 | // 77 | // All Operation methods can return a [HandlerError] to fail requests with a custom [HandlerErrorType] and structured [Failure]. 78 | // Arbitrary errors from handler methods are turned into [HandlerErrorTypeInternal], when using the Nexus SDK's 79 | // HTTP handler, their details are logged and hidden from the caller. Other handler implementations may expose internal 80 | // error information to callers. 81 | type OperationHandler[I, O any] interface { 82 | // Start handles requests for starting an operation. Return [HandlerStartOperationResultSync] to respond 83 | // successfully - inline, or [HandlerStartOperationResultAsync] to indicate that an asynchronous operation was 84 | // started. Return an [OperationError] to indicate that an operation completed as failed or 85 | // canceled. 86 | Start(ctx context.Context, input I, options StartOperationOptions) (HandlerStartOperationResult[O], error) 87 | // GetResult handles requests to get the result of an asynchronous operation. Return non error result to respond 88 | // successfully - inline, or error with [ErrOperationStillRunning] to indicate that an asynchronous operation is 89 | // still running. Return an [OperationError] to indicate that an operation completed as failed or 90 | // canceled. 91 | // 92 | // When [GetOperationResultOptions.Wait] is greater than zero, this request should be treated as a long poll. 93 | // Long poll requests have a server side timeout, configurable via [HandlerOptions.GetResultTimeout], and exposed 94 | // via context deadline. The context deadline is decoupled from the application level Wait duration. 95 | // 96 | // It is the implementor's responsiblity to respect the client's wait duration and return in a timely fashion. 97 | // Consider using a derived context that enforces the wait timeout when implementing this method and return 98 | // [ErrOperationStillRunning] when that context expires as shown in the [Handler] example. 99 | // 100 | // NOTE: Experimental 101 | GetResult(ctx context.Context, token string, options GetOperationResultOptions) (O, error) 102 | // GetInfo handles requests to get information about an asynchronous operation. 103 | // 104 | // NOTE: Experimental 105 | GetInfo(ctx context.Context, token string, options GetOperationInfoOptions) (*OperationInfo, error) 106 | // Cancel handles requests to cancel an asynchronous operation. 107 | // Cancelation in Nexus is: 108 | // 1. asynchronous - returning from this method only ensures that cancelation is delivered, it may later be 109 | // ignored by the underlying operation implemention. 110 | // 2. idempotent - implementors should ignore duplicate cancelations for the same operation. 111 | Cancel(ctx context.Context, token string, options CancelOperationOptions) error 112 | 113 | mustEmbedUnimplementedOperation() 114 | } 115 | 116 | type syncOperation[I, O any] struct { 117 | UnimplementedOperation[I, O] 118 | 119 | Handler func(context.Context, I, StartOperationOptions) (O, error) 120 | name string 121 | } 122 | 123 | // NewSyncOperation is a helper for creating a synchronous-only [Operation] from a given name and handler function. 124 | func NewSyncOperation[I, O any](name string, handler func(context.Context, I, StartOperationOptions) (O, error)) Operation[I, O] { 125 | return &syncOperation[I, O]{ 126 | name: name, 127 | Handler: handler, 128 | } 129 | } 130 | 131 | // Name implements Operation. 132 | func (h *syncOperation[I, O]) Name() string { 133 | return h.name 134 | } 135 | 136 | // Start implements Operation. 137 | func (h *syncOperation[I, O]) Start(ctx context.Context, input I, options StartOperationOptions) (HandlerStartOperationResult[O], error) { 138 | o, err := h.Handler(ctx, input, options) 139 | if err != nil { 140 | return nil, err 141 | } 142 | return &HandlerStartOperationResultSync[O]{Value: o}, err 143 | } 144 | 145 | // A Service is a container for a group of operations. 146 | type Service struct { 147 | Name string 148 | 149 | operations map[string]RegisterableOperation 150 | } 151 | 152 | // NewService constructs a [Service]. 153 | func NewService(name string) *Service { 154 | return &Service{ 155 | Name: name, 156 | operations: make(map[string]RegisterableOperation), 157 | } 158 | } 159 | 160 | // Register one or more operations. 161 | // Returns an error if duplicate operations were registered with the same name or when trying to register an operation 162 | // with no name. 163 | // 164 | // Can be called multiple times and is not thread safe. 165 | func (s *Service) Register(operations ...RegisterableOperation) error { 166 | var dups []string 167 | for _, op := range operations { 168 | if op.Name() == "" { 169 | return fmt.Errorf("tried to register an operation with no name") 170 | } 171 | if _, found := s.operations[op.Name()]; found { 172 | dups = append(dups, op.Name()) 173 | } else { 174 | s.operations[op.Name()] = op 175 | } 176 | } 177 | if len(dups) > 0 { 178 | return fmt.Errorf("duplicate operations: %s", strings.Join(dups, ", ")) 179 | } 180 | return nil 181 | } 182 | 183 | // MustRegister registers one or more operations. 184 | // Panics if duplicate operations were registered with the same name or when trying to register an operation with no 185 | // name. 186 | // 187 | // Can be called multiple times and is not thread safe. 188 | func (s *Service) MustRegister(operations ...RegisterableOperation) { 189 | if err := s.Register(operations...); err != nil { 190 | panic(err) 191 | } 192 | } 193 | 194 | // Operation returns an operation by name or nil if not found. 195 | func (s *Service) Operation(name string) RegisterableOperation { 196 | return s.operations[name] 197 | } 198 | 199 | // MiddlewareFunc is a function which receives an OperationHandler and returns another OperationHandler. 200 | // If the middleware wants to stop the chain before any handler is called, it can return an error. 201 | // 202 | // To get [HandlerInfo] for the current handler, call [ExtractHandlerInfo] with the given context. 203 | // 204 | // NOTE: Experimental 205 | type MiddlewareFunc func(ctx context.Context, next OperationHandler[any, any]) (OperationHandler[any, any], error) 206 | 207 | // A ServiceRegistry registers services and constructs a [Handler] that dispatches operations requests to those services. 208 | type ServiceRegistry struct { 209 | services map[string]*Service 210 | middleware []MiddlewareFunc 211 | } 212 | 213 | // NewServiceRegistry constructs an empty [ServiceRegistry]. 214 | func NewServiceRegistry() *ServiceRegistry { 215 | return &ServiceRegistry{ 216 | services: make(map[string]*Service), 217 | middleware: make([]MiddlewareFunc, 0), 218 | } 219 | } 220 | 221 | // Register one or more service. 222 | // Returns an error if duplicate operations were registered with the same name or when trying to register a service with 223 | // no name. 224 | // 225 | // Can be called multiple times and is not thread safe. 226 | func (r *ServiceRegistry) Register(services ...*Service) error { 227 | var dups []string 228 | for _, service := range services { 229 | if service.Name == "" { 230 | return fmt.Errorf("tried to register a service with no name") 231 | } 232 | if _, found := r.services[service.Name]; found { 233 | dups = append(dups, service.Name) 234 | } else { 235 | r.services[service.Name] = service 236 | } 237 | } 238 | if len(dups) > 0 { 239 | return fmt.Errorf("duplicate services: %s", strings.Join(dups, ", ")) 240 | } 241 | return nil 242 | } 243 | 244 | // Use registers one or more middleware to be applied to all operation method invocations across all registered 245 | // services. Middleware is applied in registration order. If called multiple times, newly registered middleware will be 246 | // applied after any previously registered ones. 247 | // 248 | // NOTE: Experimental 249 | func (s *ServiceRegistry) Use(middleware ...MiddlewareFunc) { 250 | s.middleware = append(s.middleware, middleware...) 251 | } 252 | 253 | // NewHandler creates a [Handler] that dispatches requests to registered operations based on their name. 254 | func (r *ServiceRegistry) NewHandler() (Handler, error) { 255 | if len(r.services) == 0 { 256 | return nil, errors.New("must register at least one service") 257 | } 258 | for _, service := range r.services { 259 | if len(service.operations) == 0 { 260 | return nil, fmt.Errorf("service %q has no operations registered", service.Name) 261 | } 262 | } 263 | 264 | return ®istryHandler{services: r.services, middlewares: r.middleware}, nil 265 | } 266 | 267 | type registryHandler struct { 268 | UnimplementedHandler 269 | 270 | services map[string]*Service 271 | middlewares []MiddlewareFunc 272 | } 273 | 274 | func (r *registryHandler) operationHandler(ctx context.Context) (OperationHandler[any, any], error) { 275 | options := ExtractHandlerInfo(ctx) 276 | s, ok := r.services[options.Service] 277 | if !ok { 278 | return nil, HandlerErrorf(HandlerErrorTypeNotFound, "service %q not found", options.Service) 279 | } 280 | h, ok := s.operations[options.Operation] 281 | if !ok { 282 | return nil, HandlerErrorf(HandlerErrorTypeNotFound, "operation %q not found", options.Operation) 283 | } 284 | 285 | var handler OperationHandler[any, any] 286 | handler = &rootOperationHandler{h: h} 287 | for i := len(r.middlewares) - 1; i >= 0; i-- { 288 | var err error 289 | handler, err = r.middlewares[i](ctx, handler) 290 | if err != nil { 291 | return nil, err 292 | } 293 | } 294 | return handler, nil 295 | } 296 | 297 | // CancelOperation implements Handler. 298 | func (r *registryHandler) CancelOperation(ctx context.Context, service, operation, token string, options CancelOperationOptions) error { 299 | h, err := r.operationHandler(ctx) 300 | if err != nil { 301 | return err 302 | } 303 | return h.Cancel(ctx, token, options) 304 | } 305 | 306 | // operationHandlerInfo implements Handler. 307 | func (r *registryHandler) GetOperationInfo(ctx context.Context, service, operation, token string, options GetOperationInfoOptions) (*OperationInfo, error) { 308 | h, err := r.operationHandler(ctx) 309 | if err != nil { 310 | return nil, err 311 | } 312 | return h.GetInfo(ctx, token, options) 313 | } 314 | 315 | // operationHandlerResult implements Handler. 316 | func (r *registryHandler) GetOperationResult(ctx context.Context, service, operation, token string, options GetOperationResultOptions) (any, error) { 317 | h, err := r.operationHandler(ctx) 318 | if err != nil { 319 | return nil, err 320 | } 321 | return h.GetResult(ctx, token, options) 322 | } 323 | 324 | // StartOperation implements Handler. 325 | func (r *registryHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 326 | s, ok := r.services[service] 327 | if !ok { 328 | return nil, HandlerErrorf(HandlerErrorTypeNotFound, "service %q not found", service) 329 | } 330 | ro, ok := s.operations[operation] 331 | if !ok { 332 | return nil, HandlerErrorf(HandlerErrorTypeNotFound, "operation %q not found", operation) 333 | } 334 | 335 | h, err := r.operationHandler(ctx) 336 | if err != nil { 337 | return nil, err 338 | } 339 | m, _ := reflect.TypeOf(ro).MethodByName("Start") 340 | inputType := m.Type.In(2) 341 | iptr := reflect.New(inputType).Interface() 342 | if err := input.Consume(iptr); err != nil { 343 | // TODO: log the error? Do we need to accept a logger for this single line? 344 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid input") 345 | } 346 | return h.Start(ctx, reflect.ValueOf(iptr).Elem().Interface(), options) 347 | } 348 | 349 | type rootOperationHandler struct { 350 | UnimplementedOperation[any, any] 351 | h RegisterableOperation 352 | } 353 | 354 | func (r *rootOperationHandler) Cancel(ctx context.Context, token string, options CancelOperationOptions) error { 355 | // NOTE: We could avoid reflection here if we put the Cancel method on RegisterableOperation but it doesn't seem 356 | // worth it since we need reflection for the generic methods. 357 | m, _ := reflect.TypeOf(r.h).MethodByName("Cancel") 358 | values := m.Func.Call([]reflect.Value{reflect.ValueOf(r.h), reflect.ValueOf(ctx), reflect.ValueOf(token), reflect.ValueOf(options)}) 359 | if values[0].IsNil() { 360 | return nil 361 | } 362 | return values[0].Interface().(error) 363 | } 364 | 365 | func (r *rootOperationHandler) GetInfo(ctx context.Context, token string, options GetOperationInfoOptions) (*OperationInfo, error) { 366 | // NOTE: We could avoid reflection here if we put the GetInfo method on RegisterableOperation but it doesn't 367 | // seem worth it since we need reflection for the generic methods. 368 | m, _ := reflect.TypeOf(r.h).MethodByName("GetInfo") 369 | values := m.Func.Call([]reflect.Value{reflect.ValueOf(r.h), reflect.ValueOf(ctx), reflect.ValueOf(token), reflect.ValueOf(options)}) 370 | if !values[1].IsNil() { 371 | return nil, values[1].Interface().(error) 372 | } 373 | ret := values[0].Interface() 374 | return ret.(*OperationInfo), nil 375 | } 376 | 377 | func (r *rootOperationHandler) GetResult(ctx context.Context, token string, options GetOperationResultOptions) (any, error) { 378 | m, _ := reflect.TypeOf(r.h).MethodByName("GetResult") 379 | values := m.Func.Call([]reflect.Value{reflect.ValueOf(r.h), reflect.ValueOf(ctx), reflect.ValueOf(token), reflect.ValueOf(options)}) 380 | if !values[1].IsNil() { 381 | return nil, values[1].Interface().(error) 382 | } 383 | ret := values[0].Interface() 384 | return ret, nil 385 | } 386 | 387 | func (r *rootOperationHandler) Start(ctx context.Context, input any, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 388 | m, _ := reflect.TypeOf(r.h).MethodByName("Start") 389 | values := m.Func.Call([]reflect.Value{reflect.ValueOf(r.h), reflect.ValueOf(ctx), reflect.ValueOf(input), reflect.ValueOf(options)}) 390 | if !values[1].IsNil() { 391 | return nil, values[1].Interface().(error) 392 | } 393 | ret := values[0].Interface() 394 | return ret.(HandlerStartOperationResult[any]), nil 395 | } 396 | 397 | // ExecuteOperation is the type safe version of [HTTPClient.ExecuteOperation]. 398 | // It accepts input of type I and returns output of type O, removing the need to consume the [LazyValue] returned by the 399 | // client method. 400 | // 401 | // ref := NewOperationReference[MyInput, MyOutput]("my-operation") 402 | // out, err := ExecuteOperation(ctx, client, ref, MyInput{}, options) // returns MyOutput, error 403 | func ExecuteOperation[I, O any](ctx context.Context, client *HTTPClient, operation OperationReference[I, O], input I, request ExecuteOperationOptions) (O, error) { 404 | var o O 405 | value, err := client.ExecuteOperation(ctx, operation.Name(), input, request) 406 | if err != nil { 407 | return o, err 408 | } 409 | return o, value.Consume(&o) 410 | } 411 | 412 | // StartOperation is the type safe version of [HTTPClient.StartOperation]. 413 | // It accepts input of type I and returns a [ClientStartOperationResult] of type O, removing the need to consume the 414 | // [LazyValue] returned by the client method. 415 | func StartOperation[I, O any](ctx context.Context, client *HTTPClient, operation OperationReference[I, O], input I, request StartOperationOptions) (*ClientStartOperationResult[O], error) { 416 | result, err := client.StartOperation(ctx, operation.Name(), input, request) 417 | if err != nil { 418 | return nil, err 419 | } 420 | if result.Successful != nil { 421 | var o O 422 | if err := result.Successful.Consume(&o); err != nil { 423 | return nil, err 424 | } 425 | return &ClientStartOperationResult[O]{ 426 | Successful: o, 427 | Links: result.Links, 428 | }, nil 429 | } 430 | handle := OperationHandle[O]{ 431 | client: client, 432 | Operation: operation.Name(), 433 | ID: result.Pending.ID, 434 | Token: result.Pending.Token, 435 | } 436 | return &ClientStartOperationResult[O]{ 437 | Pending: &handle, 438 | Links: result.Links, 439 | }, nil 440 | } 441 | 442 | // NewHandle is the type safe version of [HTTPClient.NewHandle]. 443 | // The [Handle.GetResult] method will return an output of type O. 444 | func NewHandle[I, O any](client *HTTPClient, operation OperationReference[I, O], token string) (*OperationHandle[O], error) { 445 | if token == "" { 446 | return nil, errEmptyOperationToken 447 | } 448 | return &OperationHandle[O]{ 449 | client: client, 450 | Operation: operation.Name(), 451 | ID: token, // Duplicate token as ID for the deprecation period. 452 | Token: token, 453 | }, nil 454 | } 455 | -------------------------------------------------------------------------------- /nexus/operation_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "strconv" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | var bytesIOOperation = NewSyncOperation("bytes-io", func(ctx context.Context, input []byte, options StartOperationOptions) ([]byte, error) { 14 | return append(input, []byte(", world")...), nil 15 | }) 16 | 17 | var noValueOperation = NewSyncOperation("no-value", func(ctx context.Context, input NoValue, options StartOperationOptions) (NoValue, error) { 18 | return nil, nil 19 | }) 20 | 21 | var numberValidatorOperation = NewSyncOperation("number-validator", func(ctx context.Context, input int, options StartOperationOptions) (int, error) { 22 | if input == 0 { 23 | return 0, NewOperationFailedError("cannot process 0") 24 | } 25 | return input, nil 26 | }) 27 | 28 | type asyncNumberValidatorOperation struct { 29 | UnimplementedOperation[int, int] 30 | } 31 | 32 | func (h *asyncNumberValidatorOperation) Name() string { 33 | return "async-number-validator" 34 | } 35 | 36 | func (h *asyncNumberValidatorOperation) Start(ctx context.Context, input int, options StartOperationOptions) (HandlerStartOperationResult[int], error) { 37 | return &HandlerStartOperationResultAsync{OperationID: fmt.Sprintf("%d", input)}, nil 38 | } 39 | 40 | func (h *asyncNumberValidatorOperation) GetResult(ctx context.Context, token string, options GetOperationResultOptions) (int, error) { 41 | if token == "0" { 42 | return 0, NewOperationFailedError("cannot process 0") 43 | } 44 | return strconv.Atoi(token) 45 | } 46 | 47 | func (h *asyncNumberValidatorOperation) Cancel(ctx context.Context, token string, options CancelOperationOptions) error { 48 | if options.Header.Get("fail") != "" { 49 | return fmt.Errorf("intentionally failed") 50 | } 51 | return nil 52 | } 53 | 54 | func (h *asyncNumberValidatorOperation) GetInfo(ctx context.Context, token string, options GetOperationInfoOptions) (*OperationInfo, error) { 55 | if options.Header.Get("fail") != "" { 56 | return nil, fmt.Errorf("intentionally failed") 57 | } 58 | return &OperationInfo{Token: token, State: OperationStateRunning}, nil 59 | } 60 | 61 | var asyncNumberValidatorOperationInstance = &asyncNumberValidatorOperation{} 62 | 63 | func TestRegistrationErrors(t *testing.T) { 64 | reg := NewServiceRegistry() 65 | svc := NewService(testService) 66 | err := svc.Register(NewSyncOperation("", func(ctx context.Context, i int, soo StartOperationOptions) (int, error) { return 5, nil })) 67 | require.ErrorContains(t, err, "tried to register an operation with no name") 68 | 69 | err = svc.Register(numberValidatorOperation, numberValidatorOperation) 70 | require.ErrorContains(t, err, "duplicate operations: "+numberValidatorOperation.Name()) 71 | 72 | _, err = reg.NewHandler() 73 | require.ErrorContains(t, err, "must register at least one service") 74 | 75 | require.ErrorContains(t, reg.Register(NewService("")), "tried to register a service with no name") 76 | // Reset operations to trigger an error. 77 | svc.operations = nil 78 | require.NoError(t, reg.Register(svc)) 79 | 80 | _, err = reg.NewHandler() 81 | require.ErrorContains(t, err, fmt.Sprintf("service %q has no operations registered", testService)) 82 | } 83 | 84 | func TestExecuteOperation(t *testing.T) { 85 | registry := NewServiceRegistry() 86 | svc := NewService(testService) 87 | require.NoError(t, svc.Register( 88 | numberValidatorOperation, 89 | bytesIOOperation, 90 | noValueOperation, 91 | )) 92 | require.NoError(t, registry.Register(svc)) 93 | handler, err := registry.NewHandler() 94 | require.NoError(t, err) 95 | 96 | ctx, client, teardown := setup(t, handler) 97 | defer teardown() 98 | 99 | result, err := ExecuteOperation(ctx, client, numberValidatorOperation, 3, ExecuteOperationOptions{}) 100 | require.NoError(t, err) 101 | require.Equal(t, 3, result) 102 | 103 | ref := NewOperationReference[int, int](numberValidatorOperation.Name()) 104 | result, err = ExecuteOperation(ctx, client, ref, 3, ExecuteOperationOptions{}) 105 | require.NoError(t, err) 106 | require.Equal(t, 3, result) 107 | 108 | _, err = ExecuteOperation(ctx, client, numberValidatorOperation, 0, ExecuteOperationOptions{}) 109 | var unsuccessfulError *OperationError 110 | require.ErrorAs(t, err, &unsuccessfulError) 111 | 112 | bResult, err := ExecuteOperation(ctx, client, bytesIOOperation, []byte("hello"), ExecuteOperationOptions{}) 113 | require.NoError(t, err) 114 | require.Equal(t, []byte("hello, world"), bResult) 115 | 116 | nResult, err := ExecuteOperation(ctx, client, noValueOperation, nil, ExecuteOperationOptions{}) 117 | require.NoError(t, err) 118 | require.Nil(t, nResult) 119 | } 120 | 121 | func TestStartOperation(t *testing.T) { 122 | registry := NewServiceRegistry() 123 | svc := NewService(testService) 124 | require.NoError(t, svc.Register( 125 | numberValidatorOperation, 126 | asyncNumberValidatorOperationInstance, 127 | )) 128 | require.NoError(t, registry.Register(svc)) 129 | 130 | handler, err := registry.NewHandler() 131 | require.NoError(t, err) 132 | 133 | ctx, client, teardown := setup(t, handler) 134 | defer teardown() 135 | 136 | result, err := StartOperation(ctx, client, numberValidatorOperation, 3, StartOperationOptions{}) 137 | require.NoError(t, err) 138 | require.Equal(t, 3, result.Successful) 139 | 140 | result, err = StartOperation(ctx, client, asyncNumberValidatorOperationInstance, 3, StartOperationOptions{}) 141 | require.NoError(t, err) 142 | value, err := result.Pending.GetResult(ctx, GetOperationResultOptions{}) 143 | require.NoError(t, err) 144 | require.Equal(t, 3, value) 145 | handle, err := NewHandle(client, asyncNumberValidatorOperationInstance, result.Pending.Token) 146 | require.NoError(t, err) 147 | value, err = handle.GetResult(ctx, GetOperationResultOptions{}) 148 | require.NoError(t, err) 149 | require.Equal(t, 3, value) 150 | } 151 | 152 | func TestCancelOperation(t *testing.T) { 153 | registry := NewServiceRegistry() 154 | svc := NewService(testService) 155 | require.NoError(t, svc.Register( 156 | asyncNumberValidatorOperationInstance, 157 | )) 158 | require.NoError(t, registry.Register(svc)) 159 | 160 | handler, err := registry.NewHandler() 161 | require.NoError(t, err) 162 | 163 | ctx, client, teardown := setup(t, handler) 164 | defer teardown() 165 | 166 | result, err := StartOperation(ctx, client, asyncNumberValidatorOperationInstance, 3, StartOperationOptions{}) 167 | require.NoError(t, err) 168 | require.NoError(t, result.Pending.Cancel(ctx, CancelOperationOptions{})) 169 | var handlerError *HandlerError 170 | require.ErrorAs(t, result.Pending.Cancel(ctx, CancelOperationOptions{Header: Header{"fail": "1"}}), &handlerError) 171 | require.Equal(t, HandlerErrorTypeInternal, handlerError.Type) 172 | require.Equal(t, "internal server error", handlerError.Cause.Error()) 173 | } 174 | 175 | func TestGetOperationInfo(t *testing.T) { 176 | registry := NewServiceRegistry() 177 | svc := NewService(testService) 178 | require.NoError(t, svc.Register( 179 | asyncNumberValidatorOperationInstance, 180 | )) 181 | require.NoError(t, registry.Register(svc)) 182 | 183 | handler, err := registry.NewHandler() 184 | require.NoError(t, err) 185 | 186 | ctx, client, teardown := setup(t, handler) 187 | defer teardown() 188 | 189 | result, err := StartOperation(ctx, client, asyncNumberValidatorOperationInstance, 3, StartOperationOptions{}) 190 | require.NoError(t, err) 191 | info, err := result.Pending.GetInfo(ctx, GetOperationInfoOptions{}) 192 | require.NoError(t, err) 193 | require.Equal(t, &OperationInfo{Token: "3", ID: "3", State: OperationStateRunning}, info) 194 | _, err = result.Pending.GetInfo(ctx, GetOperationInfoOptions{Header: Header{"fail": "1"}}) 195 | var handlerError *HandlerError 196 | require.ErrorAs(t, err, &handlerError) 197 | require.Equal(t, HandlerErrorTypeInternal, handlerError.Type) 198 | require.Equal(t, "internal server error", handlerError.Cause.Error()) 199 | } 200 | 201 | type authRejectionHandler struct { 202 | UnimplementedOperation[NoValue, NoValue] 203 | } 204 | 205 | func (h *authRejectionHandler) Name() string { 206 | return "async-number-validator" 207 | } 208 | 209 | func (h *authRejectionHandler) Start(ctx context.Context, input NoValue, options StartOperationOptions) (HandlerStartOperationResult[NoValue], error) { 210 | return nil, HandlerErrorf(HandlerErrorTypeUnauthorized, "unauthorized in test") 211 | } 212 | 213 | func (h *authRejectionHandler) GetResult(ctx context.Context, token string, options GetOperationResultOptions) (NoValue, error) { 214 | return nil, HandlerErrorf(HandlerErrorTypeUnauthorized, "unauthorized in test") 215 | } 216 | 217 | func (h *authRejectionHandler) Cancel(ctx context.Context, token string, options CancelOperationOptions) error { 218 | return HandlerErrorf(HandlerErrorTypeUnauthorized, "unauthorized in test") 219 | } 220 | 221 | func (h *authRejectionHandler) GetInfo(ctx context.Context, token string, options GetOperationInfoOptions) (*OperationInfo, error) { 222 | return nil, HandlerErrorf(HandlerErrorTypeUnauthorized, "unauthorized in test") 223 | } 224 | 225 | func TestHandlerError(t *testing.T) { 226 | var handlerError *HandlerError 227 | 228 | registry := NewServiceRegistry() 229 | svc := NewService(testService) 230 | require.NoError(t, svc.Register(&authRejectionHandler{})) 231 | require.NoError(t, registry.Register(svc)) 232 | 233 | handler, err := registry.NewHandler() 234 | require.NoError(t, err) 235 | 236 | ctx, client, teardown := setup(t, handler) 237 | defer teardown() 238 | 239 | _, err = StartOperation(ctx, client, &authRejectionHandler{}, nil, StartOperationOptions{}) 240 | require.ErrorAs(t, err, &handlerError) 241 | require.Equal(t, HandlerErrorTypeUnauthorized, handlerError.Type) 242 | require.Equal(t, "unauthorized in test", handlerError.Cause.Error()) 243 | 244 | handle, err := NewHandle(client, &authRejectionHandler{}, "dont-care") 245 | require.NoError(t, err) 246 | 247 | _, err = handle.GetInfo(ctx, GetOperationInfoOptions{}) 248 | require.ErrorAs(t, err, &handlerError) 249 | require.Equal(t, HandlerErrorTypeUnauthorized, handlerError.Type) 250 | require.Equal(t, "unauthorized in test", handlerError.Cause.Error()) 251 | 252 | err = handle.Cancel(ctx, CancelOperationOptions{}) 253 | require.ErrorAs(t, err, &handlerError) 254 | require.Equal(t, HandlerErrorTypeUnauthorized, handlerError.Type) 255 | require.Equal(t, "unauthorized in test", handlerError.Cause.Error()) 256 | 257 | _, err = handle.GetResult(ctx, GetOperationResultOptions{}) 258 | require.ErrorAs(t, err, &handlerError) 259 | require.Equal(t, HandlerErrorTypeUnauthorized, handlerError.Type) 260 | require.Equal(t, "unauthorized in test", handlerError.Cause.Error()) 261 | } 262 | 263 | func TestInputOutputType(t *testing.T) { 264 | require.True(t, reflect.TypeOf(3).AssignableTo(numberValidatorOperation.InputType())) 265 | require.False(t, reflect.TypeOf("s").AssignableTo(numberValidatorOperation.InputType())) 266 | 267 | require.True(t, reflect.TypeOf(3).AssignableTo(numberValidatorOperation.OutputType())) 268 | require.False(t, reflect.TypeOf("s").AssignableTo(numberValidatorOperation.OutputType())) 269 | } 270 | 271 | func TestOperationInterceptor(t *testing.T) { 272 | registry := NewServiceRegistry() 273 | svc := NewService(testService) 274 | require.NoError(t, svc.Register( 275 | asyncNumberValidatorOperationInstance, 276 | )) 277 | 278 | var logs []string 279 | // Register the logging middleware after the auth middleware to ensure the auth middleware is called first. 280 | // any middleware that returns an error will prevent the operation from being called. 281 | registry.Use(newAuthMiddleware("auth-key"), newLoggingMiddleware(func(log string) { 282 | logs = append(logs, log) 283 | })) 284 | require.NoError(t, registry.Register(svc)) 285 | 286 | handler, err := registry.NewHandler() 287 | require.NoError(t, err) 288 | 289 | ctx, client, teardown := setup(t, handler) 290 | defer teardown() 291 | 292 | _, err = StartOperation(ctx, client, asyncNumberValidatorOperationInstance, 3, StartOperationOptions{}) 293 | require.ErrorContains(t, err, "unauthorized") 294 | 295 | authHeader := map[string]string{"authorization": "auth-key"} 296 | result, err := StartOperation(ctx, client, asyncNumberValidatorOperationInstance, 3, StartOperationOptions{ 297 | Header: authHeader, 298 | }) 299 | require.NoError(t, err) 300 | require.ErrorContains(t, result.Pending.Cancel(ctx, CancelOperationOptions{}), "unauthorized") 301 | require.NoError(t, result.Pending.Cancel(ctx, CancelOperationOptions{Header: authHeader})) 302 | // Assert the logger only contains calls from successful operations. 303 | require.Len(t, logs, 2) 304 | require.Contains(t, logs[0], "starting operation async-number-validator") 305 | require.Contains(t, logs[1], "cancel operation async-number-validator") 306 | } 307 | 308 | func newAuthMiddleware(authKey string) MiddlewareFunc { 309 | return func(ctx context.Context, next OperationHandler[any, any]) (OperationHandler[any, any], error) { 310 | info := ExtractHandlerInfo(ctx) 311 | if info.Header.Get("authorization") != authKey { 312 | return nil, HandlerErrorf(HandlerErrorTypeUnauthorized, "unauthorized") 313 | } 314 | return next, nil 315 | } 316 | } 317 | 318 | type loggingOperation struct { 319 | UnimplementedOperation[any, any] 320 | Operation OperationHandler[any, any] 321 | name string 322 | output func(string) 323 | } 324 | 325 | func (lo *loggingOperation) Start(ctx context.Context, input any, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 326 | lo.output(fmt.Sprintf("starting operation %s", lo.name)) 327 | return lo.Operation.Start(ctx, input, options) 328 | } 329 | 330 | func (lo *loggingOperation) GetResult(ctx context.Context, id string, options GetOperationResultOptions) (any, error) { 331 | lo.output(fmt.Sprintf("getting result for operation %s", lo.name)) 332 | return lo.Operation.GetResult(ctx, id, options) 333 | } 334 | 335 | func (lo *loggingOperation) Cancel(ctx context.Context, id string, options CancelOperationOptions) error { 336 | lo.output(fmt.Sprintf("cancel operation %s", lo.name)) 337 | return lo.Operation.Cancel(ctx, id, options) 338 | } 339 | 340 | func (lo *loggingOperation) GetInfo(ctx context.Context, id string, options GetOperationInfoOptions) (*OperationInfo, error) { 341 | lo.output(fmt.Sprintf("getting info for operation %s", lo.name)) 342 | return lo.Operation.GetInfo(ctx, id, options) 343 | } 344 | 345 | func newLoggingMiddleware(output func(string)) MiddlewareFunc { 346 | return func(ctx context.Context, next OperationHandler[any, any]) (OperationHandler[any, any], error) { 347 | info := ExtractHandlerInfo(ctx) 348 | 349 | return &loggingOperation{ 350 | Operation: next, 351 | name: info.Operation, 352 | output: output, 353 | }, nil 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /nexus/options.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // StartOperationOptions are options for the StartOperation client and server APIs. 8 | type StartOperationOptions struct { 9 | // Header contains the request header fields either received by the server or to be sent by the client. 10 | // 11 | // Header will always be non empty in server methods and can be optionally set in the client API. 12 | // 13 | // Header values set here will overwrite any SDK-provided values for the same key. 14 | // 15 | // Header keys with the "content-" prefix are reserved for [Serializer] headers and should not be set in the 16 | // client API; they are not available to server [Handler] and [Operation] implementations. 17 | Header Header 18 | // Callbacks are used to deliver completion of async operations. 19 | // This value may optionally be set by the client and should be called by a handler upon completion if the started operation is async. 20 | // 21 | // Implement a [CompletionHandler] and expose it as an HTTP handler to handle async completions. 22 | CallbackURL string 23 | // Optional header fields set by a client that are required to be attached to the callback request when an 24 | // asynchronous operation completes. 25 | CallbackHeader Header 26 | // Request ID that may be used by the server handler to dedupe a start request. 27 | // By default a v4 UUID will be generated by the client. 28 | RequestID string 29 | // Links contain arbitrary caller information. Handlers may use these links as 30 | // metadata on resources associated with and operation. 31 | Links []Link 32 | } 33 | 34 | // GetOperationResultOptions are options for the GetOperationResult client and server APIs. 35 | type GetOperationResultOptions struct { 36 | // Header contains the request header fields either received by the server or to be sent by the client. 37 | // 38 | // Header will always be non empty in server methods and can be optionally set in the client API. 39 | // 40 | // Header values set here will overwrite any SDK-provided values for the same key. 41 | Header Header 42 | // If non-zero, reflects the duration the caller has indicated that it wants to wait for operation completion, 43 | // turning the request into a long poll. 44 | Wait time.Duration 45 | } 46 | 47 | // GetOperationInfoOptions are options for the GetOperationInfo client and server APIs. 48 | type GetOperationInfoOptions struct { 49 | // Header contains the request header fields either received by the server or to be sent by the client. 50 | // 51 | // Header will always be non empty in server methods and can be optionally set in the client API. 52 | // 53 | // Header values set here will overwrite any SDK-provided values for the same key. 54 | Header Header 55 | } 56 | 57 | // CancelOperationOptions are options for the CancelOperation client and server APIs. 58 | type CancelOperationOptions struct { 59 | // Header contains the request header fields either received by the server or to be sent by the client. 60 | // 61 | // Header will always be non empty in server methods and can be optionally set in the client API. 62 | // 63 | // Header values set here will overwrite any SDK-provided values for the same key. 64 | Header Header 65 | } 66 | -------------------------------------------------------------------------------- /nexus/serializer.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "reflect" 9 | ) 10 | 11 | // A Reader is a container for a [Header] and an [io.Reader]. 12 | // It is used to stream inputs and outputs in the various client and server APIs. 13 | type Reader struct { 14 | // ReaderCloser contains request or response data. May be nil for empty data. 15 | io.ReadCloser 16 | // Header that should include information on how to deserialize this content. 17 | // Headers constructed by the framework always have lower case keys. 18 | // User provided keys are considered case-insensitive by the framework. 19 | Header Header 20 | } 21 | 22 | // A Content is a container for a [Header] and a byte slice. 23 | // It is used by the SDK's [Serializer] interface implementations. 24 | type Content struct { 25 | // Header that should include information on how to deserialize this content. 26 | // Headers constructed by the framework always have lower case keys. 27 | // User provided keys are considered case-insensitive by the framework. 28 | Header Header 29 | // Data contains request or response data. May be nil for empty data. 30 | Data []byte 31 | } 32 | 33 | // A LazyValue holds a value encoded in an underlying [Reader]. 34 | // 35 | // ⚠️ When a LazyValue is returned from a client - if directly accessing the [Reader] - it must be read it in its 36 | // entirety and closed to free up the associated HTTP connection. Otherwise the [LazyValue.Consume] method must be 37 | // called. 38 | // 39 | // ⚠️ When a LazyValue is passed to a server handler, it must not be used after the returning from the handler method. 40 | type LazyValue struct { 41 | serializer Serializer 42 | Reader *Reader 43 | } 44 | 45 | // Create a new [LazyValue] from a given serializer and reader. 46 | func NewLazyValue(serializer Serializer, reader *Reader) *LazyValue { 47 | return &LazyValue{ 48 | serializer: serializer, 49 | Reader: reader, 50 | } 51 | } 52 | 53 | // Consume consumes the lazy value, decodes it from the underlying [Reader], and stores the result in the value pointed 54 | // to by v. 55 | // 56 | // var v int 57 | // err := lazyValue.Consume(&v) 58 | func (l *LazyValue) Consume(v any) error { 59 | defer l.Reader.Close() 60 | data, err := io.ReadAll(l.Reader) 61 | if err != nil { 62 | return err 63 | } 64 | return l.serializer.Deserialize(&Content{ 65 | Header: l.Reader.Header, 66 | Data: data, 67 | }, v) 68 | } 69 | 70 | // Serializer is used by the framework to serialize/deserialize input and output. 71 | // To customize serialization logic, implement this interface and provide your implementation to framework methods such 72 | // as [NewHTTPClient] and [NewHTTPHandler]. 73 | // By default, the SDK supports serialization of JSONables, byte slices, and nils. 74 | type Serializer interface { 75 | // Serialize encodes a value into a [Content]. 76 | Serialize(any) (*Content, error) 77 | // Deserialize decodes a [Content] into a given reference. 78 | Deserialize(*Content, any) error 79 | } 80 | 81 | // FailureConverter is used by the framework to transform [error] instances to and from [Failure] instances. 82 | // To customize conversion logic, implement this interface and provide your implementation to framework methods such as 83 | // [NewClient] and [NewHTTPHandler]. 84 | // By default the SDK translates only error messages, losing type information and struct fields. 85 | type FailureConverter interface { 86 | // ErrorToFailure converts an [error] to a [Failure]. 87 | // Implementors should take a best-effort approach and never fail this method. 88 | // Note that the provided error may be nil. 89 | ErrorToFailure(error) Failure 90 | // ErrorToFailure converts a [Failure] to an [error]. 91 | // Implementors should take a best-effort approach and never fail this method. 92 | FailureToError(Failure) error 93 | } 94 | 95 | var anyType = reflect.TypeOf((*any)(nil)).Elem() 96 | 97 | // ErrSerializerIncompatible is a sentinel error emitted by [Serializer] implementations to signal that a serializer is 98 | // incompatible with a given value or [Content]. 99 | var ErrSerializerIncompatible = errors.New("incompatible serializer") 100 | 101 | // CompositeSerializer is a [Serializer] that composes multiple serializers together. 102 | // During serialization, it tries each serializer in sequence until it finds a compatible serializer for the given value. 103 | // During deserialization, it tries each serializer in reverse sequence until it finds a compatible serializer for the 104 | // given content. 105 | type CompositeSerializer []Serializer 106 | 107 | func (c CompositeSerializer) Serialize(v any) (*Content, error) { 108 | for _, l := range c { 109 | p, err := l.Serialize(v) 110 | if err != nil { 111 | if errors.Is(err, ErrSerializerIncompatible) { 112 | continue 113 | } 114 | return nil, err 115 | } 116 | return p, nil 117 | } 118 | return nil, ErrSerializerIncompatible 119 | } 120 | 121 | func (c CompositeSerializer) Deserialize(content *Content, v any) error { 122 | lenc := len(c) 123 | for i := range c { 124 | l := c[lenc-i-1] 125 | if err := l.Deserialize(content, v); err != nil { 126 | if errors.Is(err, ErrSerializerIncompatible) { 127 | continue 128 | } 129 | return err 130 | } 131 | return nil 132 | } 133 | return ErrSerializerIncompatible 134 | } 135 | 136 | var _ Serializer = CompositeSerializer{} 137 | 138 | type jsonSerializer struct{} 139 | 140 | func (jsonSerializer) Deserialize(c *Content, v any) error { 141 | if !isMediaTypeJSON(c.Header["type"]) { 142 | return ErrSerializerIncompatible 143 | } 144 | return json.Unmarshal(c.Data, &v) 145 | } 146 | 147 | func (jsonSerializer) Serialize(v any) (*Content, error) { 148 | data, err := json.Marshal(v) 149 | if err != nil { 150 | return nil, err 151 | } 152 | return &Content{ 153 | Header: Header{ 154 | "type": "application/json", 155 | }, 156 | Data: data, 157 | }, nil 158 | } 159 | 160 | var _ Serializer = jsonSerializer{} 161 | 162 | // NilSerializer is a [Serializer] that supports serialization of nil values. 163 | type NilSerializer struct{} 164 | 165 | func (NilSerializer) Deserialize(c *Content, v any) error { 166 | if len(c.Data) > 0 { 167 | return ErrSerializerIncompatible 168 | } 169 | rv := reflect.ValueOf(v) 170 | if rv.Kind() != reflect.Pointer { 171 | return fmt.Errorf("cannot deserialize into non pointer: %v", v) 172 | } 173 | if rv.IsNil() { 174 | return fmt.Errorf("cannot deserialize into nil pointer: %v", v) 175 | } 176 | re := rv.Elem() 177 | if !re.CanSet() { 178 | return fmt.Errorf("non settable type: %v", v) 179 | } 180 | // Set the zero value for the given type. 181 | re.Set(reflect.Zero(re.Type())) 182 | 183 | return nil 184 | } 185 | 186 | func (NilSerializer) Serialize(v any) (*Content, error) { 187 | if v != nil { 188 | rv := reflect.ValueOf(v) 189 | if !(rv.Kind() == reflect.Pointer && rv.IsNil()) { 190 | return nil, ErrSerializerIncompatible 191 | } 192 | } 193 | return &Content{ 194 | Header: Header{}, 195 | Data: nil, 196 | }, nil 197 | } 198 | 199 | var _ Serializer = NilSerializer{} 200 | 201 | type byteSliceSerializer struct{} 202 | 203 | func (byteSliceSerializer) Deserialize(c *Content, v any) error { 204 | if !isMediaTypeOctetStream(c.Header["type"]) { 205 | return ErrSerializerIncompatible 206 | } 207 | if bPtr, ok := v.(*[]byte); ok { 208 | if bPtr == nil { 209 | return fmt.Errorf("cannot deserialize into nil pointer: %v", v) 210 | } 211 | *bPtr = c.Data 212 | return nil 213 | } 214 | // v is *any 215 | rv := reflect.ValueOf(v) 216 | if rv.Kind() != reflect.Pointer { 217 | return fmt.Errorf("cannot deserialize into non pointer: %v", v) 218 | } 219 | if rv.IsNil() { 220 | return fmt.Errorf("cannot deserialize into nil pointer: %v", v) 221 | } 222 | if rv.Elem().Type() != anyType { 223 | return fmt.Errorf("unsupported value type for content: %v", v) 224 | } 225 | rv.Elem().Set(reflect.ValueOf(c.Data)) 226 | return nil 227 | } 228 | 229 | func (byteSliceSerializer) Serialize(v any) (*Content, error) { 230 | if b, ok := v.([]byte); ok { 231 | return &Content{ 232 | Header: Header{ 233 | "type": "application/octet-stream", 234 | }, 235 | Data: b, 236 | }, nil 237 | } 238 | return nil, ErrSerializerIncompatible 239 | } 240 | 241 | var _ Serializer = byteSliceSerializer{} 242 | 243 | var defaultSerializer Serializer = CompositeSerializer([]Serializer{NilSerializer{}, byteSliceSerializer{}, jsonSerializer{}}) 244 | 245 | // DefaultSerializer returns the SDK's default [Serializer] that handles serialization to and from JSONables, byte 246 | // slices, and nil. 247 | func DefaultSerializer() Serializer { 248 | return defaultSerializer 249 | } 250 | 251 | type failureErrorFailureConverter struct{} 252 | 253 | // ErrorToFailure implements FailureConverter. 254 | func (e failureErrorFailureConverter) ErrorToFailure(err error) Failure { 255 | if err == nil { 256 | return Failure{} 257 | } 258 | if fe, ok := err.(*FailureError); ok { 259 | return fe.Failure 260 | } 261 | return Failure{ 262 | Message: err.Error(), 263 | } 264 | } 265 | 266 | // FailureToError implements FailureConverter. 267 | func (e failureErrorFailureConverter) FailureToError(f Failure) error { 268 | return &FailureError{f} 269 | } 270 | 271 | var defaultFailureConverter FailureConverter = failureErrorFailureConverter{} 272 | 273 | // DefaultFailureConverter returns the SDK's default [FailureConverter] implementation. Arbitrary errors are converted 274 | // to a simple [Failure] object with just the Message popluated and [FailureError] instances to their underlying 275 | // [Failure] instance. [Failure] instances are converted to [FailureError] to allow access to the full failure metadata 276 | // and details if available. 277 | func DefaultFailureConverter() FailureConverter { 278 | return defaultFailureConverter 279 | } 280 | -------------------------------------------------------------------------------- /nexus/serializer_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestJSONSerializer(t *testing.T) { 13 | var err error 14 | var c *Content 15 | s := jsonSerializer{} 16 | c, err = s.Serialize(1) 17 | require.NoError(t, err) 18 | require.Equal(t, Header{"type": "application/json"}, c.Header) 19 | var i int 20 | err = s.Deserialize(c, &i) 21 | require.NoError(t, err) 22 | require.Equal(t, 1, i) 23 | } 24 | 25 | func TestNilSerializer(t *testing.T) { 26 | var err error 27 | var c *Content 28 | s := NilSerializer{} 29 | _, err = s.Serialize(1) 30 | require.ErrorIs(t, err, ErrSerializerIncompatible) 31 | 32 | c, err = s.Serialize(nil) 33 | require.NoError(t, err) 34 | require.Equal(t, Header{}, c.Header) 35 | var out any 36 | require.NoError(t, s.Deserialize(c, &out)) 37 | require.Equal(t, nil, out) 38 | 39 | type st struct{ Member string } 40 | // struct is set to zero value 41 | structIn := st{Member: "gold"} 42 | require.NoError(t, s.Deserialize(c, &structIn)) 43 | require.Equal(t, st{}, structIn) 44 | 45 | // nil pointer 46 | type NoValue *struct{} 47 | var nv NoValue 48 | 49 | c, err = s.Serialize(nv) 50 | require.NoError(t, err) 51 | require.NoError(t, s.Deserialize(c, &nv)) 52 | require.Equal(t, NoValue(nil), nv) 53 | } 54 | 55 | func TestByteSliceSerializer(t *testing.T) { 56 | var err error 57 | var c *Content 58 | s := byteSliceSerializer{} 59 | _, err = s.Serialize(1) 60 | require.ErrorIs(t, err, ErrSerializerIncompatible) 61 | 62 | // decode into byte slice 63 | c, err = s.Serialize([]byte("abc")) 64 | require.NoError(t, err) 65 | require.Equal(t, Header{"type": "application/octet-stream"}, c.Header) 66 | var out []byte 67 | require.NoError(t, s.Deserialize(c, &out)) 68 | require.Equal(t, []byte("abc"), out) 69 | 70 | c, err = s.Serialize([]byte("abc")) 71 | require.NoError(t, err) 72 | require.Equal(t, Header{"type": "application/octet-stream"}, c.Header) 73 | // decode into nil pointer fails 74 | var pout *[]byte 75 | require.ErrorContains(t, s.Deserialize(c, pout), "cannot deserialize into nil pointer") 76 | // decode into any 77 | var aout any 78 | require.NoError(t, s.Deserialize(c, &aout)) 79 | require.Equal(t, []byte("abc"), aout) 80 | } 81 | 82 | func TestDefaultSerializer(t *testing.T) { 83 | var err error 84 | var c *Content 85 | s := defaultSerializer 86 | 87 | // JSON 88 | var i int 89 | c, err = s.Serialize(1) 90 | require.NoError(t, err) 91 | require.NoError(t, s.Deserialize(c, &i)) 92 | require.Equal(t, 1, i) 93 | 94 | // byte slice 95 | var b []byte 96 | c, err = s.Serialize([]byte("abc")) 97 | require.NoError(t, err) 98 | require.NoError(t, s.Deserialize(c, &b)) 99 | require.Equal(t, []byte("abc"), b) 100 | 101 | // nil 102 | var a any 103 | c, err = s.Serialize(nil) 104 | require.NoError(t, err) 105 | require.NoError(t, s.Deserialize(c, &a)) 106 | require.Equal(t, nil, a) 107 | } 108 | 109 | // There's zero chance of concurrent updates in the test where this is used. Don't bother locking. 110 | type customSerializer struct { 111 | encoded int 112 | decoded int 113 | } 114 | 115 | func (c *customSerializer) Serialize(v any) (*Content, error) { 116 | vint := v.(int) 117 | c.encoded++ 118 | return &Content{ 119 | Header: map[string]string{ 120 | "custom": strconv.Itoa(vint), 121 | }, 122 | }, nil 123 | } 124 | 125 | func (c *customSerializer) Deserialize(s *Content, v any) error { 126 | vintPtr := v.(*int) 127 | decoded, err := strconv.Atoi(s.Header["custom"]) 128 | if err != nil { 129 | return err 130 | } 131 | *vintPtr = decoded 132 | c.decoded++ 133 | return nil 134 | } 135 | 136 | func TestCustomSerializer(t *testing.T) { 137 | svc := NewService(testService) 138 | registry := NewServiceRegistry() 139 | require.NoError(t, svc.Register( 140 | numberValidatorOperation, 141 | asyncNumberValidatorOperationInstance, 142 | )) 143 | require.NoError(t, registry.Register(svc)) 144 | handler, err := registry.NewHandler() 145 | require.NoError(t, err) 146 | 147 | c := &customSerializer{} 148 | ctx, client, teardown := setupCustom(t, handler, c, nil) 149 | defer teardown() 150 | 151 | result, err := ExecuteOperation(ctx, client, numberValidatorOperation, 3, ExecuteOperationOptions{}) 152 | require.NoError(t, err) 153 | require.Equal(t, 3, result) 154 | 155 | // Async triggers GetResult, test this too. 156 | result, err = ExecuteOperation(ctx, client, asyncNumberValidatorOperationInstance, 3, ExecuteOperationOptions{}) 157 | require.NoError(t, err) 158 | require.Equal(t, 3, result) 159 | 160 | require.Equal(t, 4, c.decoded) 161 | require.Equal(t, 4, c.encoded) 162 | } 163 | 164 | func TestDefaultFailureConverterArbitraryError(t *testing.T) { 165 | sourceErr := errors.New("test") 166 | var f Failure 167 | conv := defaultFailureConverter 168 | 169 | f = conv.ErrorToFailure(sourceErr) 170 | convErr := conv.FailureToError(f) 171 | require.Equal(t, sourceErr.Error(), convErr.Error()) 172 | } 173 | 174 | func TestDefaultFailureConverterFailureError(t *testing.T) { 175 | sourceErr := &FailureError{ 176 | Failure: Failure{ 177 | Message: "test", 178 | Metadata: map[string]string{"key": "value"}, 179 | Details: []byte(`"details"`), 180 | }, 181 | } 182 | var f Failure 183 | conv := defaultFailureConverter 184 | 185 | f = conv.ErrorToFailure(sourceErr) 186 | convErr := conv.FailureToError(f) 187 | require.Equal(t, sourceErr, convErr) 188 | } 189 | 190 | type customFailureConverter struct{} 191 | 192 | var errCustom = errors.New("custom") 193 | 194 | // ErrorToFailure implements FailureConverter. 195 | func (c customFailureConverter) ErrorToFailure(err error) Failure { 196 | return Failure{ 197 | Message: err.Error(), 198 | Metadata: map[string]string{ 199 | "type": "custom", 200 | }, 201 | } 202 | } 203 | 204 | // FailureToError implements FailureConverter. 205 | func (c customFailureConverter) FailureToError(f Failure) error { 206 | if f.Metadata["type"] != "custom" { 207 | return errors.New(f.Message) 208 | } 209 | return fmt.Errorf("%w: %s", errCustom, f.Message) 210 | } 211 | 212 | func TestCustomFailureConverter(t *testing.T) { 213 | svc := NewService(testService) 214 | registry := NewServiceRegistry() 215 | require.NoError(t, svc.Register( 216 | numberValidatorOperation, 217 | asyncNumberValidatorOperationInstance, 218 | )) 219 | require.NoError(t, registry.Register(svc)) 220 | handler, err := registry.NewHandler() 221 | require.NoError(t, err) 222 | 223 | c := customFailureConverter{} 224 | ctx, client, teardown := setupCustom(t, handler, nil, c) 225 | defer teardown() 226 | 227 | _, err = ExecuteOperation(ctx, client, numberValidatorOperation, 0, ExecuteOperationOptions{}) 228 | require.ErrorIs(t, err, errCustom) 229 | 230 | // Async triggers GetResult, test this too. 231 | _, err = ExecuteOperation(ctx, client, asyncNumberValidatorOperationInstance, 0, ExecuteOperationOptions{}) 232 | require.ErrorIs(t, err, errCustom) 233 | } 234 | -------------------------------------------------------------------------------- /nexus/server_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestWriteFailure_GenericError(t *testing.T) { 15 | h := baseHTTPHandler{ 16 | logger: slog.Default(), 17 | failureConverter: defaultFailureConverter, 18 | } 19 | 20 | writer := httptest.NewRecorder() 21 | h.writeFailure(writer, fmt.Errorf("foo")) 22 | 23 | require.Equal(t, http.StatusInternalServerError, writer.Code) 24 | require.Equal(t, contentTypeJSON, writer.Header().Get("Content-Type")) 25 | 26 | var failure *Failure 27 | require.NoError(t, json.Unmarshal(writer.Body.Bytes(), &failure)) 28 | require.Equal(t, "internal server error", failure.Message) 29 | } 30 | 31 | func TestWriteFailure_HandlerError(t *testing.T) { 32 | h := baseHTTPHandler{ 33 | logger: slog.Default(), 34 | failureConverter: defaultFailureConverter, 35 | } 36 | 37 | writer := httptest.NewRecorder() 38 | h.writeFailure(writer, HandlerErrorf(HandlerErrorTypeBadRequest, "foo")) 39 | 40 | require.Equal(t, http.StatusBadRequest, writer.Code) 41 | require.Equal(t, contentTypeJSON, writer.Header().Get("Content-Type")) 42 | 43 | var failure *Failure 44 | require.NoError(t, json.Unmarshal(writer.Body.Bytes(), &failure)) 45 | require.Equal(t, "foo", failure.Message) 46 | } 47 | 48 | func TestWriteFailure_OperationError(t *testing.T) { 49 | h := baseHTTPHandler{ 50 | logger: slog.Default(), 51 | failureConverter: defaultFailureConverter, 52 | } 53 | 54 | writer := httptest.NewRecorder() 55 | h.writeFailure(writer, NewCanceledOperationError(fmt.Errorf("canceled"))) 56 | 57 | require.Equal(t, statusOperationFailed, writer.Code) 58 | require.Equal(t, contentTypeJSON, writer.Header().Get("Content-Type")) 59 | require.Equal(t, string(OperationStateCanceled), writer.Header().Get(headerOperationState)) 60 | 61 | var failure *Failure 62 | require.NoError(t, json.Unmarshal(writer.Body.Bytes(), &failure)) 63 | require.Equal(t, "canceled", failure.Message) 64 | } 65 | -------------------------------------------------------------------------------- /nexus/setup_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | const testTimeout = time.Second * 5 15 | const testService = "Ser/vic e" 16 | const getResultMaxTimeout = time.Millisecond * 300 17 | 18 | func setupCustom(t *testing.T, handler Handler, serializer Serializer, failureConverter FailureConverter) (ctx context.Context, client *HTTPClient, teardown func()) { 19 | ctx, cancel := context.WithTimeout(context.Background(), testTimeout) 20 | 21 | httpHandler := NewHTTPHandler(HandlerOptions{ 22 | GetResultTimeout: getResultMaxTimeout, 23 | Handler: handler, 24 | Serializer: serializer, 25 | FailureConverter: failureConverter, 26 | }) 27 | 28 | listener, err := net.Listen("tcp", "localhost:0") 29 | require.NoError(t, err) 30 | client, err = NewHTTPClient(HTTPClientOptions{ 31 | BaseURL: fmt.Sprintf("http://%s/", listener.Addr().String()), 32 | Service: testService, 33 | Serializer: serializer, 34 | FailureConverter: failureConverter, 35 | }) 36 | require.NoError(t, err) 37 | 38 | go func() { 39 | // Ignore for test purposes 40 | _ = http.Serve(listener, httpHandler) 41 | }() 42 | 43 | return ctx, client, func() { 44 | cancel() 45 | listener.Close() 46 | } 47 | } 48 | 49 | func setup(t *testing.T, handler Handler) (ctx context.Context, client *HTTPClient, teardown func()) { 50 | return setupCustom(t, handler, nil, nil) 51 | } 52 | 53 | func setupForCompletion(t *testing.T, handler CompletionHandler, serializer Serializer, failureConverter FailureConverter) (ctx context.Context, callbackURL string, teardown func()) { 54 | ctx, cancel := context.WithTimeout(context.Background(), testTimeout) 55 | 56 | httpHandler := NewCompletionHTTPHandler(CompletionHandlerOptions{ 57 | Handler: handler, 58 | Serializer: serializer, 59 | FailureConverter: failureConverter, 60 | }) 61 | 62 | listener, err := net.Listen("tcp", "localhost:0") 63 | require.NoError(t, err) 64 | callbackURL = fmt.Sprintf("http://%s/callback?a=b", listener.Addr().String()) 65 | 66 | go func() { 67 | // Ignore for test purposes 68 | _ = http.Serve(listener, httpHandler) 69 | }() 70 | 71 | return ctx, callbackURL, func() { 72 | cancel() 73 | listener.Close() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /nexus/start_test.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/url" 10 | "testing" 11 | "time" 12 | 13 | "github.com/google/uuid" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | type successHandler struct { 18 | UnimplementedHandler 19 | } 20 | 21 | func (h *successHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 22 | var body []byte 23 | if err := input.Consume(&body); err != nil { 24 | return nil, err 25 | } 26 | if service != testService { 27 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "unexpected service: %s", service) 28 | } 29 | if operation != "i need to/be escaped" { 30 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "unexpected operation: %s", operation) 31 | } 32 | if options.CallbackURL != "http://test/callback" { 33 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "unexpected callback URL: %s", options.CallbackURL) 34 | } 35 | if options.CallbackHeader.Get("callback-test") != "ok" { 36 | return nil, HandlerErrorf( 37 | HandlerErrorTypeBadRequest, 38 | "invalid 'callback-test' callback header: %q", 39 | options.CallbackHeader.Get("callback-test"), 40 | ) 41 | } 42 | if options.Header.Get("test") != "ok" { 43 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'test' header: %q", options.Header.Get("test")) 44 | } 45 | if options.Header.Get("nexus-callback-callback-test") != "" { 46 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "callback header not omitted from options Header") 47 | } 48 | if options.Header.Get("User-Agent") != userAgent { 49 | return nil, HandlerErrorf(HandlerErrorTypeBadRequest, "invalid 'User-Agent' header: %q", options.Header.Get("User-Agent")) 50 | } 51 | 52 | return &HandlerStartOperationResultSync[any]{Value: body}, nil 53 | } 54 | 55 | func TestSuccess(t *testing.T) { 56 | ctx, client, teardown := setup(t, &successHandler{}) 57 | defer teardown() 58 | 59 | requestBody := []byte{0x00, 0x01} 60 | 61 | response, err := client.ExecuteOperation(ctx, "i need to/be escaped", requestBody, ExecuteOperationOptions{ 62 | CallbackURL: "http://test/callback", 63 | CallbackHeader: Header{"callback-test": "ok"}, 64 | Header: Header{"test": "ok"}, 65 | }) 66 | require.NoError(t, err) 67 | var responseBody []byte 68 | err = response.Consume(&responseBody) 69 | require.NoError(t, err) 70 | require.Equal(t, requestBody, responseBody) 71 | } 72 | 73 | type errorHandler struct { 74 | UnimplementedHandler 75 | } 76 | 77 | func (h *errorHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 78 | return nil, &HandlerError{ 79 | Type: HandlerErrorTypeInternal, 80 | Cause: errors.New("Some error"), 81 | RetryBehavior: HandlerErrorRetryBehaviorNonRetryable, 82 | } 83 | } 84 | 85 | func TestHandlerErrorRetryBehavior(t *testing.T) { 86 | ctx, client, teardown := setup(t, &errorHandler{}) 87 | defer teardown() 88 | 89 | _, err := client.ExecuteOperation(ctx, "op", nil, ExecuteOperationOptions{}) 90 | var handlerErr *HandlerError 91 | require.ErrorAs(t, err, &handlerErr) 92 | require.Equal(t, HandlerErrorRetryBehaviorNonRetryable, handlerErr.RetryBehavior) 93 | } 94 | 95 | type requestIDEchoHandler struct { 96 | UnimplementedHandler 97 | } 98 | 99 | func (h *requestIDEchoHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 100 | return &HandlerStartOperationResultSync[any]{ 101 | Value: []byte(options.RequestID), 102 | }, nil 103 | } 104 | 105 | func TestClientRequestID(t *testing.T) { 106 | ctx, client, teardown := setup(t, &requestIDEchoHandler{}) 107 | defer teardown() 108 | 109 | type testcase struct { 110 | name string 111 | request StartOperationOptions 112 | validator func(*testing.T, []byte) 113 | } 114 | 115 | cases := []testcase{ 116 | { 117 | name: "unspecified", 118 | request: StartOperationOptions{}, 119 | validator: func(t *testing.T, body []byte) { 120 | _, err := uuid.ParseBytes(body) 121 | require.NoError(t, err) 122 | }, 123 | }, 124 | { 125 | name: "provided directly", 126 | request: StartOperationOptions{RequestID: "direct"}, 127 | validator: func(t *testing.T, body []byte) { 128 | require.Equal(t, []byte("direct"), body) 129 | }, 130 | }, 131 | } 132 | for _, c := range cases { 133 | c := c 134 | t.Run(c.name, func(t *testing.T) { 135 | result, err := client.StartOperation(ctx, "foo", nil, c.request) 136 | require.NoError(t, err) 137 | response := result.Successful 138 | require.NotNil(t, response) 139 | var responseBody []byte 140 | err = response.Consume(&responseBody) 141 | require.NoError(t, err) 142 | c.validator(t, responseBody) 143 | }) 144 | } 145 | } 146 | 147 | type jsonHandler struct { 148 | UnimplementedHandler 149 | } 150 | 151 | func (h *jsonHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 152 | var s string 153 | if err := input.Consume(&s); err != nil { 154 | return nil, err 155 | } 156 | return &HandlerStartOperationResultSync[any]{Value: s}, nil 157 | } 158 | 159 | func TestJSON(t *testing.T) { 160 | ctx, client, teardown := setup(t, &jsonHandler{}) 161 | defer teardown() 162 | 163 | result, err := client.StartOperation(ctx, "foo", "success", StartOperationOptions{}) 164 | require.NoError(t, err) 165 | response := result.Successful 166 | require.NotNil(t, response) 167 | var operationResult string 168 | err = response.Consume(&operationResult) 169 | require.NoError(t, err) 170 | require.Equal(t, "success", operationResult) 171 | } 172 | 173 | type echoHandler struct { 174 | UnimplementedHandler 175 | } 176 | 177 | func (h *echoHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 178 | var output any 179 | switch options.Header.Get("input-type") { 180 | case "reader": 181 | output = input.Reader 182 | case "content": 183 | data, err := io.ReadAll(input.Reader) 184 | if err != nil { 185 | return nil, err 186 | } 187 | output = &Content{ 188 | Header: input.Reader.Header, 189 | Data: data, 190 | } 191 | } 192 | AddHandlerLinks(ctx, options.Links...) 193 | return &HandlerStartOperationResultSync[any]{Value: output}, nil 194 | } 195 | 196 | func TestReaderIO(t *testing.T) { 197 | ctx, client, teardown := setup(t, &echoHandler{}) 198 | defer teardown() 199 | 200 | content, err := jsonSerializer{}.Serialize("success") 201 | require.NoError(t, err) 202 | reader := &Reader{ 203 | io.NopCloser(bytes.NewReader(content.Data)), 204 | content.Header, 205 | } 206 | testCases := []struct { 207 | name string 208 | input any 209 | header Header 210 | links []Link 211 | }{ 212 | { 213 | name: "content", 214 | input: content, 215 | header: Header{"input-type": "content"}, 216 | }, 217 | { 218 | name: "reader", 219 | input: reader, 220 | header: Header{"input-type": "reader"}, 221 | links: []Link{{ 222 | URL: &url.URL{ 223 | Scheme: "https", 224 | Host: "example.com", 225 | Path: "/path/to/something", 226 | RawQuery: "param=value", 227 | }, 228 | Type: "url", 229 | }}, 230 | }, 231 | } 232 | 233 | for _, tc := range testCases { 234 | tc := tc 235 | t.Run(tc.name, func(t *testing.T) { 236 | result, err := client.StartOperation(ctx, "foo", tc.input, StartOperationOptions{Header: tc.header, Links: tc.links}) 237 | require.NoError(t, err) 238 | require.Equal(t, tc.links, result.Links) 239 | response := result.Successful 240 | require.NotNil(t, response) 241 | var operationResult string 242 | err = response.Consume(&operationResult) 243 | require.NoError(t, err) 244 | require.Equal(t, "success", operationResult) 245 | }) 246 | } 247 | } 248 | 249 | type asyncHandler struct { 250 | UnimplementedHandler 251 | } 252 | 253 | func (h *asyncHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 254 | return &HandlerStartOperationResultAsync{ 255 | OperationToken: "async", 256 | }, nil 257 | } 258 | 259 | func TestAsync(t *testing.T) { 260 | ctx, client, teardown := setup(t, &asyncHandler{}) 261 | defer teardown() 262 | 263 | result, err := client.StartOperation(ctx, "foo", nil, StartOperationOptions{}) 264 | require.NoError(t, err) 265 | require.NotNil(t, result.Pending) 266 | } 267 | 268 | type unsuccessfulHandler struct { 269 | UnimplementedHandler 270 | } 271 | 272 | func (h *unsuccessfulHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 273 | return nil, &OperationError{ 274 | // We're passing the desired state via request ID in this test. 275 | State: OperationState(options.RequestID), 276 | Cause: fmt.Errorf("intentional"), 277 | } 278 | } 279 | 280 | func TestUnsuccessful(t *testing.T) { 281 | ctx, client, teardown := setup(t, &unsuccessfulHandler{}) 282 | defer teardown() 283 | 284 | cases := []string{"canceled", "failed"} 285 | for _, c := range cases { 286 | _, err := client.StartOperation(ctx, "foo", nil, StartOperationOptions{RequestID: c}) 287 | var unsuccessfulError *OperationError 288 | require.ErrorAs(t, err, &unsuccessfulError) 289 | require.Equal(t, OperationState(c), unsuccessfulError.State) 290 | } 291 | } 292 | 293 | type timeoutEchoHandler struct { 294 | UnimplementedHandler 295 | } 296 | 297 | func (h *timeoutEchoHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 298 | deadline, set := ctx.Deadline() 299 | if !set { 300 | return &HandlerStartOperationResultSync[any]{ 301 | Value: []byte("not set"), 302 | }, nil 303 | } 304 | return &HandlerStartOperationResultSync[any]{ 305 | Value: []byte(formatDuration(time.Until(deadline))), 306 | }, nil 307 | } 308 | 309 | func TestStart_ContextDeadlinePropagated(t *testing.T) { 310 | ctx, client, teardown := setup(t, &timeoutEchoHandler{}) 311 | defer teardown() 312 | 313 | deadline, _ := ctx.Deadline() 314 | initialTimeout := time.Until(deadline) 315 | result, err := client.StartOperation(ctx, "foo", nil, StartOperationOptions{}) 316 | 317 | require.NoError(t, err) 318 | requireTimeoutPropagated(t, result, initialTimeout) 319 | } 320 | 321 | func TestStart_RequestTimeoutHeaderOverridesContextDeadline(t *testing.T) { 322 | // relies on ctx returned here having default testTimeout set greater than expected timeout 323 | ctx, client, teardown := setup(t, &timeoutEchoHandler{}) 324 | defer teardown() 325 | 326 | timeout := 100 * time.Millisecond 327 | result, err := client.StartOperation(ctx, "foo", nil, StartOperationOptions{Header: Header{HeaderRequestTimeout: formatDuration(timeout)}}) 328 | 329 | require.NoError(t, err) 330 | requireTimeoutPropagated(t, result, timeout) 331 | } 332 | 333 | func requireTimeoutPropagated(t *testing.T, result *ClientStartOperationResult[*LazyValue], expected time.Duration) { 334 | response := result.Successful 335 | require.NotNil(t, response) 336 | var responseBody []byte 337 | err := response.Consume(&responseBody) 338 | require.NoError(t, err) 339 | parsedTimeout, err := parseDuration(string(responseBody)) 340 | require.NoError(t, err) 341 | require.NotZero(t, parsedTimeout) 342 | require.LessOrEqual(t, parsedTimeout, expected) 343 | } 344 | 345 | func TestStart_TimeoutNotPropagated(t *testing.T) { 346 | _, client, teardown := setup(t, &timeoutEchoHandler{}) 347 | defer teardown() 348 | 349 | result, err := client.StartOperation(context.Background(), "foo", nil, StartOperationOptions{}) 350 | 351 | require.NoError(t, err) 352 | response := result.Successful 353 | require.NotNil(t, response) 354 | var responseBody []byte 355 | err = response.Consume(&responseBody) 356 | require.NoError(t, err) 357 | require.Equal(t, []byte("not set"), responseBody) 358 | } 359 | 360 | func TestStart_NilContentHeaderDoesNotPanic(t *testing.T) { 361 | _, client, teardown := setup(t, &requestIDEchoHandler{}) 362 | defer teardown() 363 | 364 | result, err := client.StartOperation(context.Background(), "op", &Content{Data: []byte("abc")}, StartOperationOptions{}) 365 | 366 | require.NoError(t, err) 367 | response := result.Successful 368 | require.NotNil(t, response) 369 | var responseBody []byte 370 | err = response.Consume(&responseBody) 371 | require.NoError(t, err) 372 | } 373 | -------------------------------------------------------------------------------- /nexus/unimplemented_handler.go: -------------------------------------------------------------------------------- 1 | package nexus 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | ) 7 | 8 | // UnimplementedHandler must be embedded into any [Handler] implementation for future compatibility. 9 | // It implements all methods on the [Handler] interface, returning unimplemented errors if they are not implemented by 10 | // the embedding type. 11 | type UnimplementedHandler struct{} 12 | 13 | func (h UnimplementedHandler) mustEmbedUnimplementedHandler() {} 14 | 15 | // StartOperation implements the Handler interface. 16 | func (h UnimplementedHandler) StartOperation(ctx context.Context, service, operation string, input *LazyValue, options StartOperationOptions) (HandlerStartOperationResult[any], error) { 17 | return nil, HandlerErrorf(HandlerErrorTypeNotImplemented, "not implemented") 18 | } 19 | 20 | // GetOperationResult implements the Handler interface. 21 | func (h UnimplementedHandler) GetOperationResult(ctx context.Context, service, operation, token string, options GetOperationResultOptions) (any, error) { 22 | return nil, HandlerErrorf(HandlerErrorTypeNotImplemented, "not implemented") 23 | } 24 | 25 | // GetOperationInfo implements the Handler interface. 26 | func (h UnimplementedHandler) GetOperationInfo(ctx context.Context, service, operation, token string, options GetOperationInfoOptions) (*OperationInfo, error) { 27 | return nil, HandlerErrorf(HandlerErrorTypeNotImplemented, "not implemented") 28 | } 29 | 30 | // CancelOperation implements the Handler interface. 31 | func (h UnimplementedHandler) CancelOperation(ctx context.Context, service, operation, token string, options CancelOperationOptions) error { 32 | return HandlerErrorf(HandlerErrorTypeNotImplemented, "not implemented") 33 | } 34 | 35 | // UnimplementedOperation must be embedded into any [Operation] implementation for future compatibility. 36 | // It implements all methods on the [Operation] interface except for `Name`, returning unimplemented errors if they are 37 | // not implemented by the embedding type. 38 | type UnimplementedOperation[I, O any] struct{} 39 | 40 | func (*UnimplementedOperation[I, O]) inferType(I, O) {} //nolint:unused 41 | 42 | func (*UnimplementedOperation[I, O]) mustEmbedUnimplementedOperation() {} 43 | 44 | func (*UnimplementedOperation[I, O]) InputType() reflect.Type { 45 | var zero [0]I 46 | return reflect.TypeOf(zero).Elem() 47 | } 48 | 49 | func (*UnimplementedOperation[I, O]) OutputType() reflect.Type { 50 | var zero [0]O 51 | return reflect.TypeOf(zero).Elem() 52 | } 53 | 54 | // Cancel implements Operation. 55 | func (*UnimplementedOperation[I, O]) Cancel(ctx context.Context, token string, options CancelOperationOptions) error { 56 | return HandlerErrorf(HandlerErrorTypeNotImplemented, "not implemented") 57 | } 58 | 59 | // GetInfo implements Operation. 60 | func (*UnimplementedOperation[I, O]) GetInfo(ctx context.Context, token string, options GetOperationInfoOptions) (*OperationInfo, error) { 61 | return nil, HandlerErrorf(HandlerErrorTypeNotImplemented, "not implemented") 62 | } 63 | 64 | // GetResult implements Operation. 65 | func (*UnimplementedOperation[I, O]) GetResult(ctx context.Context, token string, options GetOperationResultOptions) (O, error) { 66 | var empty O 67 | return empty, HandlerErrorf(HandlerErrorTypeNotImplemented, "not implemented") 68 | } 69 | 70 | // Start implements Operation. 71 | func (h *UnimplementedOperation[I, O]) Start(ctx context.Context, input I, options StartOperationOptions) (HandlerStartOperationResult[O], error) { 72 | return nil, HandlerErrorf(HandlerErrorTypeNotImplemented, "not implemented") 73 | } 74 | --------------------------------------------------------------------------------