Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ This library serves as an internal library as a client for the StackState platfo

Part of the receiver API is compatible with DataDog, those parts are extracted here, another part is new and comes from stackstate-openapi.

### OpenAPI connection options

`NewOpenAPIClientWithOptions` constructs an authenticated client with its own HTTP transport and a required request timeout. Supply the final Receiver URL: redirects are rejected, including same-host HTTP-to-HTTPS and trailing-slash redirects. Set `ConnectionOptions.ProxyURL` explicitly when a proxy is required; the transport does not read proxy environment variables. TLS uses the system trust store.

Provide exactly one authentication source: `APIKey` or `ServiceAccountToken`. The token callback is read on every request, including after rotation; empty credentials and header delimiters are rejected. The existing `NewOpenAPIClient` signature, `Connect()` method and legacy authentication behavior remain available unchanged.

### Bumping the openapi version

- Change the version/branch/commit sha in the `stackstate_openapi/openapi_version` file
- Run `nix develop -c ./scripts/generate_receiver_api.sh`
- Commit the generated code
- Commit the generated code
60 changes: 60 additions & 0 deletions pkg/openapiclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package openapiclient
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/url"
Expand Down Expand Up @@ -137,3 +139,61 @@ func (c openAPIClientImpl) Connect() *receiver_api.APIClient {
func makeBaseURL(url string) string {
return strings.TrimSuffix(strings.Trim(url, "/"), "/stsAgent")
}

// NewOpenAPIClientWithOptions constructs a Receiver client and its authenticated context.
func NewOpenAPIClientWithOptions(parent context.Context, opts ConnectionOptions) (*receiver_api.APIClient, context.Context, error) {
if parent == nil {
return nil, nil, errors.New("parent context is required")
}
if _, err := parseEndpoint(opts.ReceiverURL, false); err != nil {
return nil, nil, fmt.Errorf("invalid receiver URL: %w", err)
}
if opts.RequestTimeout <= 0 {
return nil, nil, errors.New("request timeout must be positive")
}
if opts.APIKey != "" && opts.ServiceAccountToken != nil {
return nil, nil, errors.New("exactly one receiver authentication source is required")
}
source := validatedTokenSource{tokenFunc: opts.ServiceAccountToken, tokenType: "ServiceBearer"}
if opts.APIKey != "" {
source = validatedTokenSource{tokenFunc: func() string { return opts.APIKey }, tokenType: "ApiKey"}
}
if _, err := source.Token(); err != nil {
return nil, nil, err
}
transport, err := newTransport(opts)
if err != nil {
return nil, nil, err
}
cfg := receiver_api.NewConfiguration()
cfg.HTTPClient = &http.Client{
Timeout: opts.RequestTimeout,
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
}
cfg.UserAgent = opts.UserAgent
cfg.Servers[0] = receiver_api.ServerConfiguration{URL: makeBaseURL(opts.ReceiverURL)}
cfg.Debug = false
authCtx := context.WithValue(parent, receiver_api.ContextOAuth2, source)
return receiver_api.NewAPIClient(cfg), authCtx, nil
}

type validatedTokenSource struct {
tokenFunc func() string
tokenType string
}

func (d validatedTokenSource) Token() (*oauth2.Token, error) {
if d.tokenFunc == nil {
return nil, ErrMissingCredential
}
token := d.tokenFunc()
if strings.TrimSpace(token) == "" {
return nil, ErrMissingCredential
}
// Reject header delimiters here so transport errors cannot echo credentials.
if strings.ContainsAny(token, "\r\n") {
return nil, ErrMissingCredential
}
return &oauth2.Token{AccessToken: token, TokenType: d.tokenType}, nil
}
42 changes: 42 additions & 0 deletions pkg/openapiclient/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package openapiclient

import (
"errors"
"net/url"
"strconv"
"strings"
"time"
)

// ConnectionOptions configures authentication and the owned Receiver transport.
type ConnectionOptions struct {
ReceiverURL string
UserAgent string
APIKey string
ServiceAccountToken func() string
ProxyURL string
InsecureSkipVerify bool
RequestTimeout time.Duration
}

// ErrMissingCredential indicates that no usable authentication credential is available.
var ErrMissingCredential = errors.New("receiver credential is empty")

func parseEndpoint(raw string, allowUserinfo bool) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.Opaque != "" {
return nil, errors.New("endpoint must be an absolute HTTP(S) URL")
}
if (!allowUserinfo && u.User != nil) || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(raw, "#") {
return nil, errors.New("endpoint contains unsupported userinfo, query or fragment")
}
if port := u.Port(); port != "" {
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
return nil, errors.New("endpoint port must be between 1 and 65535")
}
} else if strings.HasSuffix(u.Host, ":") {
return nil, errors.New("endpoint port is empty")
}
return u, nil
}
Loading
Loading