diff --git a/client.go b/client.go index 4b77662..088908e 100644 --- a/client.go +++ b/client.go @@ -5,8 +5,6 @@ import ( "fmt" "net/http" "os" - "slices" - "strings" "buf.build/gen/go/stealthrocket/dispatch-proto/connectrpc/go/dispatch/sdk/v1/sdkv1connect" sdkv1 "buf.build/gen/go/stealthrocket/dispatch-proto/protocolbuffers/go/dispatch/sdk/v1" @@ -33,7 +31,7 @@ func NewClient(opts ...ClientOption) (*Client, error) { env: os.Environ(), } for _, opt := range opts { - opt(c) + opt.configureClient(c) } if c.apiKey == "" { @@ -41,7 +39,7 @@ func NewClient(opts ...ClientOption) (*Client, error) { c.apiKeyFromEnv = true } if c.apiKey == "" { - return nil, fmt.Errorf("Dispatch API key has not been set. Use WithAPIKey(..), or set the DISPATCH_API_KEY environment variable") + return nil, fmt.Errorf("Dispatch API key has not been set. Use APIKey(..), or set the DISPATCH_API_KEY environment variable") } if c.apiUrl == "" { @@ -75,35 +73,35 @@ func NewClient(opts ...ClientOption) (*Client, error) { } // ClientOption configures a Client. -type ClientOption func(*Client) +type ClientOption interface { + configureClient(d *Client) +} + +type clientOptionFunc func(d *Client) + +func (fn clientOptionFunc) configureClient(d *Client) { + fn(d) +} -// WithAPIKey sets the Dispatch API key to use for authentication when +// APIKey sets the Dispatch API key to use for authentication when // dispatching function calls through a Client. // // It defaults to the value of the DISPATCH_API_KEY environment variable. -func WithAPIKey(apiKey string) ClientOption { - return func(c *Client) { c.apiKey = apiKey } +func APIKey(apiKey string) ClientOption { + return clientOptionFunc(func(c *Client) { c.apiKey = apiKey }) } -// WithAPIUrl sets the URL of the Dispatch API. +// APIUrl sets the URL of the Dispatch API. // // It defaults to the value of the DISPATCH_API_URL environment variable, // or DefaultApiUrl if DISPATCH_API_URL is unset. -func WithAPIUrl(apiUrl string) ClientOption { - return func(c *Client) { c.apiUrl = apiUrl } +func APIUrl(apiUrl string) ClientOption { + return clientOptionFunc(func(c *Client) { c.apiUrl = apiUrl }) } // DefaultApiUrl is the default Dispatch API URL. const DefaultApiUrl = "https://api.dispatch.run" -// WithClientEnv sets the environment variables that a Client parses -// its default configuration from. -// -// It defaults to os.Environ(). -func WithClientEnv(env ...string) ClientOption { - return func(c *Client) { c.env = slices.Clone(env) } -} - // Dispatch dispatches a function call. func (c *Client) Dispatch(ctx context.Context, call Call) (ID, error) { batch := c.Batch() @@ -115,6 +113,10 @@ func (c *Client) Dispatch(ctx context.Context, call Call) (ID, error) { return ids[0], nil } +func (c *Client) configureDispatch(d *Dispatch) { + d.client = c +} + // Batch creates a Batch. func (c *Client) Batch() Batch { return Batch{client: c} @@ -149,7 +151,7 @@ func (b *Batch) Dispatch(ctx context.Context) ([]ID, error) { if b.client.apiKeyFromEnv { return nil, fmt.Errorf("invalid DISPATCH_API_KEY: %s", redactAPIKey(b.client.apiKey)) } - return nil, fmt.Errorf("invalid Dispatch API key provided with WithAPIKey(): %s", redactAPIKey(b.client.apiKey)) + return nil, fmt.Errorf("invalid Dispatch API key provided with APIKey(..): %s", redactAPIKey(b.client.apiKey)) } return nil, err } @@ -160,17 +162,6 @@ func (b *Batch) Dispatch(ctx context.Context) ([]ID, error) { return ids, nil } -func getenv(env []string, name string) string { - var value string - for _, s := range env { - n, v, ok := strings.Cut(s, "=") - if ok && n == name { - value = v - } - } - return value -} - func redactAPIKey(s string) string { if len(s) <= 3 { // Don't redact the string if it's this short. It's not a valid API diff --git a/client_test.go b/client_test.go index e722d96..79829c5 100644 --- a/client_test.go +++ b/client_test.go @@ -12,7 +12,7 @@ func TestClient(t *testing.T) { recorder := &dispatchtest.CallRecorder{} server := dispatchtest.NewDispatchServer(recorder) - client, err := dispatch.NewClient(dispatch.WithAPIKey("foobar"), dispatch.WithAPIUrl(server.URL)) + client, err := dispatch.NewClient(dispatch.APIKey("foobar"), dispatch.APIUrl(server.URL)) if err != nil { t.Fatal(err) } @@ -34,7 +34,7 @@ func TestClientEnvConfig(t *testing.T) { recorder := &dispatchtest.CallRecorder{} server := dispatchtest.NewDispatchServer(recorder) - client, err := dispatch.NewClient(dispatch.WithClientEnv( + client, err := dispatch.NewClient(dispatch.Env( "DISPATCH_API_KEY=foobar", "DISPATCH_API_URL="+server.URL, )) @@ -59,7 +59,7 @@ func TestClientBatch(t *testing.T) { recorder := &dispatchtest.CallRecorder{} server := dispatchtest.NewDispatchServer(recorder) - client, err := dispatch.NewClient(dispatch.WithAPIKey("foobar"), dispatch.WithAPIUrl(server.URL)) + client, err := dispatch.NewClient(dispatch.APIKey("foobar"), dispatch.APIUrl(server.URL)) if err != nil { t.Fatal(err) } @@ -96,10 +96,10 @@ func TestClientBatch(t *testing.T) { } func TestClientNoAPIKey(t *testing.T) { - _, err := dispatch.NewClient(dispatch.WithClientEnv( /* i.e. no env vars */ )) + _, err := dispatch.NewClient(dispatch.Env( /* i.e. no env vars */ )) if err == nil { t.Fatalf("expected an error") - } else if err.Error() != "Dispatch API key has not been set. Use WithAPIKey(..), or set the DISPATCH_API_KEY environment variable" { + } else if err.Error() != "Dispatch API key has not been set. Use APIKey(..), or set the DISPATCH_API_KEY environment variable" { t.Errorf("unexpected error: %v", err) } } diff --git a/dispatch.go b/dispatch.go index a99d1c6..983b300 100644 --- a/dispatch.go +++ b/dispatch.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" "os" - "slices" "strings" "sync" @@ -44,30 +43,24 @@ func New(opts ...DispatchOption) (*Dispatch, error) { functions: map[string]Function{}, } for _, opt := range opts { - opt(d) + opt.configureDispatch(d) } // Prepare the endpoint URL. - var endpointUrlFromEnv string + var endpointUrlFromEnv bool if d.endpointUrl == "" { d.endpointUrl = getenv(d.env, "DISPATCH_ENDPOINT_URL") - endpointUrlFromEnv = "DISPATCH_ENDPOINT_URL" + endpointUrlFromEnv = true } if d.endpointUrl == "" { - if endpointAddr := getenv(d.env, "DISPATCH_ENDPOINT_ADDR"); endpointAddr != "" { - d.endpointUrl = fmt.Sprintf("http://%s", endpointAddr) - endpointUrlFromEnv = "DISPATCH_ENDPOINT_ADDR" - } - } - if d.endpointUrl == "" { - return nil, fmt.Errorf("Dispatch endpoint URL has not been set. Use WithEndpointUrl(..), or set the DISPATCH_ENDPOINT_URL environment variable") + return nil, fmt.Errorf("Dispatch endpoint URL has not been set. Use EndpointUrl(..), or set the DISPATCH_ENDPOINT_URL environment variable") } _, err := url.Parse(d.endpointUrl) if err != nil { - if endpointUrlFromEnv != "" { - return nil, fmt.Errorf("invalid %s: %v", endpointUrlFromEnv, d.endpointUrl) + if endpointUrlFromEnv { + return nil, fmt.Errorf("invalid DISPATCH_ENDPOINT_URL: %v", d.endpointUrl) } - return nil, fmt.Errorf("invalid endpoint URL provided via WithEndpointUrl: %v", d.endpointUrl) + return nil, fmt.Errorf("invalid endpoint URL provided via EndpointUrl(..): %v", d.endpointUrl) } // Prepare the address to serve on. @@ -92,7 +85,7 @@ func New(opts ...DispatchOption) (*Dispatch, error) { if verificationKeyFromEnv { return nil, fmt.Errorf("invalid DISPATCH_VERIFICATION_KEY: %v", d.verificationKey) } - return nil, fmt.Errorf("invalid verification key provided via WithVerificationKey: %v", d.verificationKey) + return nil, fmt.Errorf("invalid verification key provided via VerificationKey(..): %v", d.verificationKey) } } @@ -118,7 +111,7 @@ func New(opts ...DispatchOption) (*Dispatch, error) { // Optionally attach a client. if d.client == nil { var err error - d.client, err = NewClient(append(d.clientOpts, WithClientEnv(d.env...))...) + d.client, err = NewClient(append(d.clientOpts, Env(d.env...))...) if err != nil { slog.Debug("failed to setup client for the Dispatch endpoint", "error", err) d.clientErr = err @@ -129,17 +122,25 @@ func New(opts ...DispatchOption) (*Dispatch, error) { } // DispatchOption configures a Dispatch endpoint. -type DispatchOption func(d *Dispatch) +type DispatchOption interface { + configureDispatch(d *Dispatch) +} + +type dispatchOptionFunc func(d *Dispatch) + +func (fn dispatchOptionFunc) configureDispatch(d *Dispatch) { + fn(d) +} -// WithEndpointUrl sets the URL of the Dispatch endpoint. +// EndpointUrl sets the URL of the Dispatch endpoint. // // It defaults to the value of the DISPATCH_ENDPOINT_URL environment // variable. -func WithEndpointUrl(endpointUrl string) DispatchOption { - return func(d *Dispatch) { d.endpointUrl = endpointUrl } +func EndpointUrl(endpointUrl string) DispatchOption { + return dispatchOptionFunc(func(d *Dispatch) { d.endpointUrl = endpointUrl }) } -// WithVerificationKey sets the verification key to use when verifying +// VerificationKey sets the verification key to use when verifying // Dispatch request signatures. // // The key should be a PEM or base64-encoded ed25519 public key. @@ -149,30 +150,11 @@ func WithEndpointUrl(endpointUrl string) DispatchOption { // // If a verification key is not provided, request signatures will // not be validated. -func WithVerificationKey(verificationKey string) DispatchOption { - return func(d *Dispatch) { d.verificationKey = verificationKey } -} - -// WithEnv sets the environment variables that a Dispatch endpoint -// parses its default configuration from. -// -// It defaults to os.Environ(). -func WithEnv(env ...string) DispatchOption { - return func(d *Dispatch) { d.env = slices.Clone(env) } -} - -// WithClient binds a Client to a Dispatch endpoint. -// -// Binding a Client allows functions calls to be directly dispatched from -// functions registered with the endpoint, via function.Dispatch(...). -// -// The Dispatch endpoint will attempt to create a Client automatically, -// using configuration from the environment. -func WithClient(client *Client) DispatchOption { - return func(d *Dispatch) { d.client = client } +func VerificationKey(verificationKey string) DispatchOption { + return dispatchOptionFunc(func(d *Dispatch) { d.verificationKey = verificationKey }) } -// WithServeAddress sets the address that the Dispatch endpoint +// ServeAddress sets the address that the Dispatch endpoint // is served on (see Dispatch.Serve). // // Note that this is not the same as the endpoint URL, which is the @@ -181,8 +163,8 @@ func WithClient(client *Client) DispatchOption { // It defaults to the value of the DISPATCH_ENDPOINT_ADDR environment // variable, which is automatically set by the Dispatch CLI. If this // is unset, it defaults to 127.0.0.1:8000. -func WithServeAddress(addr string) DispatchOption { - return func(d *Dispatch) { d.serveAddr = addr } +func ServeAddress(addr string) DispatchOption { + return dispatchOptionFunc(func(d *Dispatch) { d.serveAddr = addr }) } // Register registers a function. diff --git a/dispatch_test.go b/dispatch_test.go index 8786fcf..a95d3df 100644 --- a/dispatch_test.go +++ b/dispatch_test.go @@ -14,13 +14,13 @@ import ( func TestDispatchEndpoint(t *testing.T) { signingKey, verificationKey := dispatchtest.KeyPair() - endpoint, server, err := dispatchtest.NewEndpoint(dispatch.WithVerificationKey(verificationKey)) + endpoint, server, err := dispatchtest.NewEndpoint(dispatch.VerificationKey(verificationKey)) if err != nil { t.Fatal(err) } defer server.Close() - client, err := server.Client(dispatchtest.WithSigningKey(signingKey)) + client, err := server.Client(dispatchtest.SigningKey(signingKey)) if err != nil { t.Fatal(err) } @@ -78,12 +78,12 @@ func TestDispatchCall(t *testing.T) { recorder := &dispatchtest.CallRecorder{} server := dispatchtest.NewDispatchServer(recorder) - client, err := dispatch.NewClient(dispatch.WithAPIKey("foobar"), dispatch.WithAPIUrl(server.URL)) + client, err := dispatch.NewClient(dispatch.APIKey("foobar"), dispatch.APIUrl(server.URL)) if err != nil { t.Fatal(err) } - endpoint, err := dispatch.New(dispatch.WithEndpointUrl("http://example.com"), dispatch.WithClient(client)) + endpoint, err := dispatch.New(dispatch.EndpointUrl("http://example.com"), client) if err != nil { t.Fatal(err) } @@ -111,7 +111,7 @@ func TestDispatchCallEnvConfig(t *testing.T) { recorder := &dispatchtest.CallRecorder{} server := dispatchtest.NewDispatchServer(recorder) - endpoint, err := dispatch.New(dispatch.WithEnv( + endpoint, err := dispatch.New(dispatch.Env( "DISPATCH_ENDPOINT_URL=http://example.com", "DISPATCH_API_KEY=foobar", "DISPATCH_API_URL="+server.URL, @@ -143,12 +143,12 @@ func TestDispatchCallsBatch(t *testing.T) { server := dispatchtest.NewDispatchServer(&recorder) - client, err := dispatch.NewClient(dispatch.WithAPIKey("foobar"), dispatch.WithAPIUrl(server.URL)) + client, err := dispatch.NewClient(dispatch.APIKey("foobar"), dispatch.APIUrl(server.URL)) if err != nil { t.Fatal(err) } - endpoint, err := dispatch.New(dispatch.WithEndpointUrl("http://example.com"), dispatch.WithClient(client)) + endpoint, err := dispatch.New(dispatch.EndpointUrl("http://example.com"), client) if err != nil { t.Fatal(err) } @@ -192,21 +192,21 @@ func TestDispatchCallsBatch(t *testing.T) { func TestDispatchEndpointURL(t *testing.T) { t.Run("missing", func(t *testing.T) { - _, err := dispatch.New(dispatch.WithEnv( /* i.e. no env vars */ )) - if err == nil || err.Error() != "Dispatch endpoint URL has not been set. Use WithEndpointUrl(..), or set the DISPATCH_ENDPOINT_URL environment variable" { + _, err := dispatch.New(dispatch.Env( /* i.e. no env vars */ )) + if err == nil || err.Error() != "Dispatch endpoint URL has not been set. Use EndpointUrl(..), or set the DISPATCH_ENDPOINT_URL environment variable" { t.Fatalf("unexpected error: %v", err) } }) t.Run("invalid", func(t *testing.T) { - _, err := dispatch.New(dispatch.WithEndpointUrl(":://::")) - if err == nil || err.Error() != "invalid endpoint URL provided via WithEndpointUrl: :://::" { + _, err := dispatch.New(dispatch.EndpointUrl(":://::")) + if err == nil || err.Error() != "invalid endpoint URL provided via EndpointUrl(..): :://::" { t.Fatalf("unexpected error: %v", err) } }) t.Run("invalid env", func(t *testing.T) { - _, err := dispatch.New(dispatch.WithEnv( + _, err := dispatch.New(dispatch.Env( "DISPATCH_ENDPOINT_URL=:://::", )) if err == nil || err.Error() != "invalid DISPATCH_ENDPOINT_URL: :://::" { @@ -218,21 +218,21 @@ func TestDispatchEndpointURL(t *testing.T) { func TestDispatchVerificationKey(t *testing.T) { t.Run("missing", func(t *testing.T) { // It's not an error to omit the verification key. - _, err := dispatch.New(dispatch.WithEndpointUrl("http://example.com")) + _, err := dispatch.New(dispatch.EndpointUrl("http://example.com")) if err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("invalid", func(t *testing.T) { - _, err := dispatch.New(dispatch.WithEndpointUrl("http://example.com"), dispatch.WithVerificationKey("foo")) - if err == nil || err.Error() != "invalid verification key provided via WithVerificationKey: foo" { + _, err := dispatch.New(dispatch.EndpointUrl("http://example.com"), dispatch.VerificationKey("foo")) + if err == nil || err.Error() != "invalid verification key provided via VerificationKey(..): foo" { t.Fatalf("unexpected error: %v", err) } }) t.Run("invalid env", func(t *testing.T) { - _, err := dispatch.New(dispatch.WithEnv( + _, err := dispatch.New(dispatch.Env( "DISPATCH_ENDPOINT_URL=http://example.com", "DISPATCH_VERIFICATION_KEY=foo", )) diff --git a/dispatchtest/endpoint.go b/dispatchtest/endpoint.go index 4fce517..0449c5b 100644 --- a/dispatchtest/endpoint.go +++ b/dispatchtest/endpoint.go @@ -25,7 +25,7 @@ func NewEndpoint(opts ...dispatch.DispatchOption) (*dispatch.Dispatch, *Endpoint mux := http.NewServeMux() server := httptest.NewServer(mux) - opts = append(opts, dispatch.WithEndpointUrl(server.URL)) + opts = append(opts, dispatch.EndpointUrl(server.URL)) endpoint, err := dispatch.New(opts...) if err != nil { server.Close() @@ -100,14 +100,14 @@ func NewEndpointClient(endpointUrl string, opts ...EndpointClientOption) (*Endpo // EndpointClientOption configures an EndpointClient. type EndpointClientOption func(*EndpointClient) -// WithSigningKey sets the signing key to use when signing requests bound +// SigningKey sets the signing key to use when signing requests bound // for the endpoint. // // The signing key should be a base64-encoded ed25519.PrivateKey, e.g. // one provided by the KeyPair helper function. // // By default the EndpointClient does not sign requests to the endpoint. -func WithSigningKey(signingKey string) EndpointClientOption { +func SigningKey(signingKey string) EndpointClientOption { return func(c *EndpointClient) { c.signingKey = signingKey } } diff --git a/env.go b/env.go new file mode 100644 index 0000000..657a7ed --- /dev/null +++ b/env.go @@ -0,0 +1,33 @@ +package dispatch + +import ( + "slices" + "strings" +) + +// Env sets the environment variables that a Dispatch endpoint +// or Client parses its default configuration from. +// +// It defaults to os.Environ(). +func Env(env ...string) interface { + DispatchOption + ClientOption +} { + return envOption(env) +} + +type envOption []string + +func (env envOption) configureDispatch(d *Dispatch) { d.env = slices.Clone(env) } +func (env envOption) configureClient(c *Client) { c.env = slices.Clone(env) } + +func getenv(env []string, name string) string { + var value string + for _, s := range env { + n, v, ok := strings.Cut(s, "=") + if ok && n == name { + value = v + } + } + return value +} diff --git a/function_test.go b/function_test.go index 3d8a728..eb83fdf 100644 --- a/function_test.go +++ b/function_test.go @@ -86,7 +86,7 @@ func TestPrimitiveFunctionDispatchWithoutClient(t *testing.T) { // It's not necessary to have valid Client configuration when // creating a Dispatch endpoint. In this case, there's no // Dispatch API key available. - endpoint, err := dispatch.New(dispatch.WithEndpointUrl("http://example.com"), dispatch.WithEnv( /* i.e. no env vars */ )) + endpoint, err := dispatch.New(dispatch.EndpointUrl("http://example.com"), dispatch.Env( /* i.e. no env vars */ )) if err != nil { t.Fatal(err) } @@ -105,7 +105,7 @@ func TestPrimitiveFunctionDispatchWithoutClient(t *testing.T) { _, err = fn.Dispatch(context.Background(), dispatch.String("bar")) if err == nil { t.Fatal("expected an error") - } else if err.Error() != "cannot dispatch function call: Dispatch API key has not been set. Use WithAPIKey(..), or set the DISPATCH_API_KEY environment variable" { + } else if err.Error() != "cannot dispatch function call: Dispatch API key has not been set. Use APIKey(..), or set the DISPATCH_API_KEY environment variable" { t.Errorf("unexpected error: %v", err) } } @@ -114,7 +114,7 @@ func TestFunctionDispatchWithoutClient(t *testing.T) { // It's not necessary to have valid Client configuration when // creating a Dispatch endpoint. In this case, there's no // Dispatch API key available. - endpoint, err := dispatch.New(dispatch.WithEndpointUrl("http://example.com"), dispatch.WithEnv( /* i.e. no env vars */ )) + endpoint, err := dispatch.New(dispatch.EndpointUrl("http://example.com"), dispatch.Env( /* i.e. no env vars */ )) if err != nil { t.Fatal(err) } @@ -133,7 +133,7 @@ func TestFunctionDispatchWithoutClient(t *testing.T) { _, err = fn.Dispatch(context.Background(), wrapperspb.String("bar")) if err == nil { t.Fatal("expected an error") - } else if err.Error() != "cannot dispatch function call: Dispatch API key has not been set. Use WithAPIKey(..), or set the DISPATCH_API_KEY environment variable" { + } else if err.Error() != "cannot dispatch function call: Dispatch API key has not been set. Use APIKey(..), or set the DISPATCH_API_KEY environment variable" { t.Errorf("unexpected error: %v", err) } }