From 22d36112675b3cb3365ce2351e8b159b2f769608 Mon Sep 17 00:00:00 2001 From: mattgd Date: Thu, 14 Nov 2024 15:15:01 -0500 Subject: [PATCH] Add widgets client and get token API support. --- pkg/widgets/README.md | 15 +++++ pkg/widgets/client.go | 115 ++++++++++++++++++++++++++++++++++++ pkg/widgets/client_test.go | 78 ++++++++++++++++++++++++ pkg/widgets/widgets.go | 26 ++++++++ pkg/widgets/widgets_test.go | 32 ++++++++++ 5 files changed, 266 insertions(+) create mode 100644 pkg/widgets/README.md create mode 100644 pkg/widgets/client.go create mode 100644 pkg/widgets/client_test.go create mode 100644 pkg/widgets/widgets.go create mode 100644 pkg/widgets/widgets_test.go diff --git a/pkg/widgets/README.md b/pkg/widgets/README.md new file mode 100644 index 0000000..7e40497 --- /dev/null +++ b/pkg/widgets/README.md @@ -0,0 +1,15 @@ +# widgets + +[![Go Report Card](https://img.shields.io/badge/dev-reference-007d9c?logo=go&logoColor=white&style=flat)](https://pkg.go.dev/github.com/workos/workos-go/v4/pkg/widgets) + +A Go package to make requests to the WorkOS Widgets API. + +## Install + +```sh +go get -u github.com/workos/workos-go/v4/pkg/widgets +``` + +## How it works + +See the [Widgets integration guide](https://workos.com/docs/widgets/guide). diff --git a/pkg/widgets/client.go b/pkg/widgets/client.go new file mode 100644 index 0000000..8930e9b --- /dev/null +++ b/pkg/widgets/client.go @@ -0,0 +1,115 @@ +package widgets + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/workos/workos-go/v4/pkg/workos_errors" + + "github.com/workos/workos-go/v4/internal/workos" +) + +// ResponseLimit is the default number of records to limit a response to. +const ResponseLimit = 10 + +// Client represents a client that performs Widgets requests to the WorkOS API. +type Client struct { + // The WorkOS API Key. It can be found in https://dashboard.workos.com/api-keys. + APIKey string + + // The http.Client that is used to manage Widgets API calls to WorkOS. + // Defaults to http.Client. + HTTPClient *http.Client + + // The endpoint to WorkOS API. Defaults to https://api.workos.com. + Endpoint string + + // The function used to encode in JSON. Defaults to json.Marshal. + JSONEncode func(v interface{}) ([]byte, error) + + once sync.Once +} + +func (c *Client) init() { + if c.HTTPClient == nil { + c.HTTPClient = &http.Client{Timeout: 10 * time.Second} + } + + if c.Endpoint == "" { + c.Endpoint = "https://api.workos.com" + } + + if c.JSONEncode == nil { + c.JSONEncode = json.Marshal + } +} + +// WidgetScope represents a widget token scope. +type WidgetScope string + +// Constants that enumerate the available GenerateLinkIntent types. +const ( + UsersTableManage WidgetScope = "widgets:users-table:manage" +) + +// GetTokenOpts contains the options to get a widget token. +type GetTokenOpts struct { + // Organization identifier to scope the widget token + OrganizationId string `json:"organization_id"` + + // AuthKit user identifier to scope the widget token + UserId string `json:"user_id"` + + // WidgetScopes to scope the widget token + Scopes []WidgetScope `json:"scopes"` +} + +// GetTokenResponse represents the generated widget token +type GetTokenResponse struct { + // Generated widget token + Token string `json:"token"` +} + +// GetToken generates a widget token based on the provided options. +func (c *Client) GetToken( + ctx context.Context, + opts GetTokenOpts, +) (string, error) { + c.once.Do(c.init) + + data, err := c.JSONEncode(opts) + if err != nil { + return "", err + } + + endpoint := fmt.Sprintf("%s/widgets/token", c.Endpoint) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(data)) + if err != nil { + return "", err + } + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("User-Agent", "workos-go/"+workos.Version) + + res, err := c.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if err = workos_errors.TryGetHTTPError(res); err != nil { + return "", err + } + + var body GetTokenResponse + + dec := json.NewDecoder(res.Body) + err = dec.Decode(&body) + return body.Token, err +} diff --git a/pkg/widgets/client_test.go b/pkg/widgets/client_test.go new file mode 100644 index 0000000..afdc6bd --- /dev/null +++ b/pkg/widgets/client_test.go @@ -0,0 +1,78 @@ +package widgets + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetToken(t *testing.T) { + tests := []struct { + scenario string + client *Client + options GetTokenOpts + expected string + err bool + }{ + { + scenario: "Request without API Key returns an error", + client: &Client{}, + err: true, + }, + { + scenario: "Request returns widget token", + client: &Client{ + APIKey: "test", + }, + options: GetTokenOpts{ + OrganizationId: "organization_id", + UserId: "user_id", + Scopes: []WidgetScope{UsersTableManage}, + }, + expected: "abc123456", + }, + } + + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(generateLinkTestHandler)) + defer server.Close() + + client := test.client + client.Endpoint = server.URL + client.HTTPClient = server.Client() + + token, err := client.GetToken(context.Background(), test.options) + if test.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.expected, token) + }) + } +} + +func generateLinkTestHandler(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "Bearer test" { + http.Error(w, "bad auth", http.StatusUnauthorized) + return + } + + body, err := json.Marshal(struct { + GetTokenResponse + }{GetTokenResponse{Token: "abc123456"}}) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(body) +} diff --git a/pkg/widgets/widgets.go b/pkg/widgets/widgets.go new file mode 100644 index 0000000..f5b0a9d --- /dev/null +++ b/pkg/widgets/widgets.go @@ -0,0 +1,26 @@ +// Package `widgets` provides a client wrapping the WorkOS Widgets API. +package widgets + +import ( + "context" +) + +// DefaultClient is the client used by SetAPIKey and Widgets functions. +var ( + DefaultClient = &Client{ + Endpoint: "https://api.workos.com", + } +) + +// SetAPIKey sets the WorkOS API key for Widgets API requests. +func SetAPIKey(apiKey string) { + DefaultClient.APIKey = apiKey +} + +// GetToken generates an ephemeral widget token based on the provided options. +func GetToken( + ctx context.Context, + opts GetTokenOpts, +) (string, error) { + return DefaultClient.GetToken(ctx, opts) +} diff --git a/pkg/widgets/widgets_test.go b/pkg/widgets/widgets_test.go new file mode 100644 index 0000000..15a00ff --- /dev/null +++ b/pkg/widgets/widgets_test.go @@ -0,0 +1,32 @@ +package widgets + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWidgetsGetToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(generateLinkTestHandler)) + defer server.Close() + + DefaultClient = &Client{ + HTTPClient: server.Client(), + Endpoint: server.URL, + } + SetAPIKey("test") + + expectedToken := "abc123456" + + token, err := GetToken(context.Background(), GetTokenOpts{ + OrganizationId: "organization_id", + UserId: "user_id", + Scopes: []WidgetScope{UsersTableManage}, + }) + + require.NoError(t, err) + require.Equal(t, expectedToken, token) +}