diff --git a/clerk.go b/clerk.go new file mode 100644 index 00000000..9c2ffdbd --- /dev/null +++ b/clerk.go @@ -0,0 +1,360 @@ +// Package clerk provides a way to communicate with the Clerk API. +// Includes types for Clerk API requests, responses and all +// available resources. +package clerk + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "sync" + "time" +) + +const ( + sdkVersion string = "v2.0.0" + clerkVersion string = "v1" +) + +const ( + // APIURL is the base URL for the Clerk API. + APIURL string = "https://api.clerk.com" +) + +// SecretKey is the Clerk secret key. Configured on a package level. +var SecretKey string + +// APIResource describes a Clerk API resource and contains fields and +// methods common to all resources. +type APIResource struct { + Response *APIResponse `json:"-"` +} + +// Read sets the response on the resource. +func (r *APIResource) Read(response *APIResponse) { + r.Response = response +} + +// APIParams implements functionality that's common to all types +// that can be used as API request parameters. +// It is recommended to embed this type to all types that will be +// used for API operation parameters. +type APIParams struct { +} + +// Add can be used to set parameters to url.Values. The method +// is currently a no-op, but is defined so that all types that +// describe API operation parameters implement the Queryable +// interface. +func (params *APIParams) Add(q url.Values) { +} + +// APIResponse describes responses coming from the Clerk API. +// Exposes some commonly used HTTP response fields along with +// the raw data in the response body. +type APIResponse struct { + Header http.Header + Status string // e.g. "200 OK" + StatusCode int // e.g. 200 + + // TraceID is a unique identifier for tracing the origin of the + // response. + // Useful for debugging purposes. + TraceID string + // RawJSON contains the response body as raw bytes. + RawJSON json.RawMessage +} + +// Success returns true for API response status codes in the +// 200-399 range, false otherwise. +func (resp *APIResponse) Success() bool { + return resp.StatusCode < 400 +} + +// NewAPIResponse creates an APIResponse from the passed http.Response +// and the raw response body. +func NewAPIResponse(resp *http.Response, body json.RawMessage) *APIResponse { + return &APIResponse{ + Header: resp.Header, + TraceID: resp.Header.Get("Clerk-Trace-Id"), + Status: resp.Status, + StatusCode: resp.StatusCode, + RawJSON: body, + } +} + +// APIRequest describes requests to the Clerk API. +type APIRequest struct { + Method string + Path string + Params Queryable +} + +// SetParams sets the APIRequest.Params. +func (req *APIRequest) SetParams(params Queryable) { + req.Params = params +} + +// NewAPIRequest creates an APIRequest with the provided HTTP method +// and path. +func NewAPIRequest(method, path string) *APIRequest { + return &APIRequest{ + Method: method, + Path: path, + } +} + +// Backend is the primary interface for communicating with the Clerk +// API. +type Backend interface { + // Call makes requests to the Clerk API. + Call(context.Context, *APIRequest, ResponseReader) error +} + +// ResponseReader reads Clerk API responses. +type ResponseReader interface { + Read(*APIResponse) +} + +// Queryable can add parameters to url.Values. +// Useful for constructing a request query string. +type Queryable interface { + Add(url.Values) +} + +// BackendConfig is used to configure a new Clerk Backend. +type BackendConfig struct { + // HTTPClient is an HTTP client instance that will be used for + // making API requests. + // If it's not set a default HTTP client will be used. + HTTPClient *http.Client + // URL is the base URL to use for API endpoints. + // If it's not set, the default value for the Backend will be used. + URL *string +} + +// NewBackend returns a default backend implementation with the +// provided configuration. +// Please note that the return type is an interface because the +// Backend is not supposed to be used directly. +func NewBackend(config *BackendConfig) Backend { + if config.HTTPClient == nil { + config.HTTPClient = httpClient + } + if config.URL == nil { + config.URL = String(APIURL) + } + return &defaultBackend{ + HTTPClient: config.HTTPClient, + URL: *config.URL, + } +} + +// GetBackend returns the library's supported backend for the Clerk +// API. +func GetBackend() Backend { + var b Backend + + backend.mu.RLock() + b = backend.Backend + backend.mu.RUnlock() + + if b != nil { + return b + } + + b = NewBackend(&BackendConfig{}) + SetBackend(b) + return b +} + +// SetBackend sets the Backend that will be used to make requests +// to the Clerk API. +// Use this method if you need to override the default Backend +// configuration. +func SetBackend(b Backend) { + backend.mu.Lock() + defer backend.mu.Unlock() + backend.Backend = b +} + +type defaultBackend struct { + HTTPClient *http.Client + URL string +} + +// Call sends requests to the Clerk API and handles the responses. +func (b *defaultBackend) Call(ctx context.Context, apiReq *APIRequest, setter ResponseReader) error { + req, err := b.newRequest(ctx, apiReq) + if err != nil { + return err + } + + return b.do(req, apiReq.Params, setter) +} + +func (b *defaultBackend) newRequest(ctx context.Context, apiReq *APIRequest) (*http.Request, error) { + path := b.URL + apiReq.Path + req, err := http.NewRequest(apiReq.Method, path, nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", SecretKey)) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("User-Agent", fmt.Sprintf("Clerk/%s SDK-Go/%s", clerkVersion, sdkVersion)) + req.Header.Add("X-Clerk-SDK", fmt.Sprintf("go/%s", sdkVersion)) + req = req.WithContext(ctx) + + return req, nil +} + +func (b *defaultBackend) do(req *http.Request, params Queryable, setter ResponseReader) error { + err := setRequestBody(req, params) + if err != nil { + return err + } + + resp, err := b.HTTPClient.Do(req) + if err != nil { + return err + } + resBody, err := io.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + return err + } + + apiResponse := NewAPIResponse(resp, resBody) + // Looks like something went wrong. Handle the error. + if !apiResponse.Success() { + return handleError(apiResponse, resBody) + } + + setter.Read(apiResponse) + err = json.Unmarshal(resBody, setter) + if err != nil { + return err + } + + return nil +} + +// Sets the params in either the request body, or the querystring +// for GET requests. +func setRequestBody(req *http.Request, params Queryable) error { + // GET requests don't have a body, but we will pass the params + // in the query string. + if req.Method == http.MethodGet { + q := req.URL.Query() + params.Add(q) + req.URL.RawQuery = q.Encode() + return nil + } + + body, err := json.Marshal(params) + if err != nil { + return err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } + + return nil +} + +// Error response handling +func handleError(resp *APIResponse, body []byte) error { + apiError := &APIErrorResponse{ + HTTPStatusCode: resp.StatusCode, + } + apiError.Read(resp) + err := json.Unmarshal(body, apiError) + if err != nil || apiError.Errors == nil { + // This is probably not an expected API error. + // Return the raw server response. + return errors.New(string(body)) + } + return apiError +} + +// The active Backend +var backend api + +// This type is a container for a Backend. Guarantees thread-safe +// access to the current Backend. +type api struct { + Backend Backend + mu sync.RWMutex +} + +// defaultHTTPTimeout is the default timeout on the http.Client used +// by the library. +const defaultHTTPTimeout = 5 * time.Second + +// The default HTTP client used for communication with the Clerk API. +var httpClient = &http.Client{ + Timeout: defaultHTTPTimeout, +} + +// APIErrorResponse is used for cases where requests to the Clerk +// API result in error responses. +type APIErrorResponse struct { + APIResource + + Errors []Error `json:"errors"` + + HTTPStatusCode int `json:"status,omitempty"` + TraceID string `json:"clerk_trace_id,omitempty"` +} + +// Error returns the marshaled representation of the APIErrorResponse. +func (resp *APIErrorResponse) Error() string { + ret, err := json.Marshal(resp) + if err != nil { + // This shouldn't happen, let's return the raw response + return string(resp.Response.RawJSON) + } + return string(ret) +} + +// Error is a representation of a single error that can occur in the +// Clerk API. +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + LongMessage string `json:"long_message"` + Meta json.RawMessage `json:"meta,omitempty"` +} + +// ListParams holds fields that are common for list API operations. +type ListParams struct { + Limit *int64 `json:"limit,omitempty"` + Offset *int64 `json:"offset,omitempty"` +} + +// Add sets list params to the passed in url.Values. +func (params ListParams) Add(q url.Values) { + if params.Limit != nil { + q.Set("limit", strconv.FormatInt(*params.Limit, 10)) + } + if params.Offset != nil { + q.Set("offset", strconv.FormatInt(*params.Offset, 10)) + } +} + +// String returns a pointer to the provided string value. +func String(v string) *string { + return &v +} + +// Int64 returns a pointer to the provided int64 value. +func Int64(v int64) *int64 { + return &v +} diff --git a/clerk_test.go b/clerk_test.go new file mode 100644 index 00000000..36f0405f --- /dev/null +++ b/clerk_test.go @@ -0,0 +1,343 @@ +package clerk + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAPIResponse(t *testing.T) { + body := []byte(`{"foo":"bar"}`) + resp := &http.Response{ + Status: "200 OK", + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{}`))), + Header: http.Header(map[string][]string{ + "Clerk-Trace-Id": {"trace-id"}, + "x-custom-header": {"custom-header"}, + }), + } + res := NewAPIResponse(resp, body) + assert.Equal(t, body, []byte(res.RawJSON)) + assert.Equal(t, "200 OK", res.Status) + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "trace-id", res.TraceID) + assert.Equal(t, resp.Header, res.Header) +} + +func TestNewBackend(t *testing.T) { + withDefaults, ok := NewBackend(&BackendConfig{}).(*defaultBackend) + require.True(t, ok) + require.NotNil(t, withDefaults.HTTPClient) + assert.Equal(t, defaultHTTPTimeout, withDefaults.HTTPClient.Timeout) + assert.Equal(t, APIURL, withDefaults.URL) + + u := "https://some.other.url" + httpClient := &http.Client{} + config := &BackendConfig{ + URL: &u, + HTTPClient: httpClient, + } + withOverrides, ok := NewBackend(config).(*defaultBackend) + require.True(t, ok) + assert.Equal(t, u, withOverrides.URL) + assert.Equal(t, httpClient, withOverrides.HTTPClient) +} + +func TestGetBackend_DataRace(t *testing.T) { + wg := &sync.WaitGroup{} + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + b, ok := GetBackend().(*defaultBackend) + require.True(t, ok) + assert.Equal(t, APIURL, b.URL) + }() + } + wg.Wait() +} + +func TestAPIErrorResponse(t *testing.T) { + // API error response that is valid JSON. The error + // string is the raw JSON. + resp := &APIErrorResponse{ + HTTPStatusCode: 200, + TraceID: "trace-id", + Errors: []Error{ + { + Code: "error-code", + Message: "message", + LongMessage: "long message", + }, + }, + } + expected := fmt.Sprintf(`{ + "status":%d, + "clerk_trace_id":"%s", + "errors":[{"code":"%s","message":"%s","long_message":"%s"}] +}`, + resp.HTTPStatusCode, + resp.TraceID, + resp.Errors[0].Code, + resp.Errors[0].Message, + resp.Errors[0].LongMessage, + ) + assert.JSONEq(t, expected, resp.Error()) +} + +// This is how you define a Clerk API resource which is ready to be +// used by the library. +type testResource struct { + APIResource + ID string `json:"id"` + Object string `json:"object"` +} + +// This is how you define types which can be used as Clerk API +// request parameters. +type testResourceParams struct { + APIParams + Name string `json:"name"` +} + +// This is how you define a Clerk API resource which can be used in +// API operations that read a list of resources. +type testResourceList struct { + APIResource + Resources []testResource `json:"data"` + TotalCount int64 `json:"total_count"` +} + +// This is how you define a type which can be used as parameters +// to a Clerk API operation that lists resources. +type testResourceListParams struct { + APIParams + ListParams + Name string `json:"name"` +} + +// We need to implement the Queryable interface. +func (params testResourceListParams) Add(q url.Values) { + q.Set("name", params.Name) + params.ListParams.Add(q) +} + +func TestBackendCall_RequestHeaders(t *testing.T) { + ctx := context.Background() + method := http.MethodPost + path := "/v1/resources" + secretKey := "sk_test_123" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, method, r.Method) + require.Equal(t, path, r.URL.Path) + + // The client sets the Authorization header correctly. + assert.Equal(t, fmt.Sprintf("Bearer %s", secretKey), r.Header.Get("Authorization")) + // The client sets the User-Agent header. + assert.Equal(t, "Clerk/v1 SDK-Go/v2.0.0", r.Header.Get("User-Agent")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + // The client includes a custom header with the SDK version. + assert.Equal(t, "go/v2.0.0", r.Header.Get("X-Clerk-SDK")) + + _, err := w.Write([]byte(`{}`)) + require.NoError(t, err) + })) + defer ts.Close() + + // Set up a mock backend which triggers requests to our test server above. + SetBackend(NewBackend(&BackendConfig{ + HTTPClient: ts.Client(), + URL: &ts.URL, + })) + + // Simulate usage for an API operation on a testResource. + // We need to initialize a request and use the Backend to send it. + SecretKey = secretKey + req := NewAPIRequest(method, path) + err := GetBackend().Call(ctx, req, &testResource{}) + require.NoError(t, err) +} + +// TestBackendCall_SuccessfulResponse_PostRequest tests that for POST +// requests (or other mutating operations) we serialize all parameters +// in the request body. +func TestBackendCall_SuccessfulResponse_PostRequest(t *testing.T) { + ctx := context.Background() + name := "the-name" + rawJSON := `{"id":"res_123","object":"resource"}` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Assert that the request parameters were passed correctly in + // the request body. + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + assert.JSONEq(t, fmt.Sprintf(`{"name":"%s"}`, name), string(body)) + + _, err = w.Write([]byte(rawJSON)) + require.NoError(t, err) + })) + defer ts.Close() + + // Set up a mock backend which triggers requests to our test server above. + SetBackend(NewBackend(&BackendConfig{ + HTTPClient: ts.Client(), + URL: &ts.URL, + })) + + // Simulate usage for an API operation on a testResource. + // We need to initialize a request and use the Backend to send it. + resource := &testResource{} + req := NewAPIRequest(http.MethodPost, "/v1/resources") + req.SetParams(&testResourceParams{Name: name}) + err := GetBackend().Call(ctx, req, resource) + require.NoError(t, err) + + // The API response has been unmarshaled in the testResource struct. + assert.Equal(t, "resource", resource.Object) + assert.Equal(t, "res_123", resource.ID) + // We stored the API response + require.NotNil(t, resource.Response) + assert.JSONEq(t, rawJSON, string(resource.Response.RawJSON)) +} + +// TestBackendCall_SuccessfulResponse_GetRequest tests that for GET +// requests which don't have a body, we serialize any parameters in +// the URL query string. +func TestBackendCall_SuccessfulResponse_GetRequest(t *testing.T) { + ctx := context.Background() + name := "the-name" + limit := 1 + rawJSON := `{"data": [{"id":"res_123","object":"resource"}], "total_count": 1}` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Assert that the request parameters were set in the URL + // query string. + q := r.URL.Query() + assert.Equal(t, name, q.Get("name")) + assert.Equal(t, strconv.Itoa(limit), q.Get("limit")) + // Optional query parameters are omitted. + assert.False(t, q.Has("offset")) + + _, err := w.Write([]byte(rawJSON)) + require.NoError(t, err) + })) + defer ts.Close() + + // Set up a mock backend which triggers requests to our test server above. + SetBackend(NewBackend(&BackendConfig{ + HTTPClient: ts.Client(), + URL: &ts.URL, + })) + + // Simulate usage for an API operation on a testResourceList. + // We need to initialize a request and use the Backend to send it. + resource := &testResourceList{} + req := NewAPIRequest(http.MethodGet, "/v1/resources") + req.SetParams(&testResourceListParams{ + ListParams: ListParams{ + Limit: Int64(int64(limit)), + }, + Name: name, + }) + err := GetBackend().Call(ctx, req, resource) + require.NoError(t, err) + + // The API response has been unmarshaled correctly into a list of + // testResource structs. + assert.Equal(t, "resource", resource.Resources[0].Object) + assert.Equal(t, "res_123", resource.Resources[0].ID) + // We stored the API response + require.NotNil(t, resource.Response) + assert.JSONEq(t, rawJSON, string(resource.Response.RawJSON)) +} + +// TestBackendCall_ParseableError tests responses with a non-successful +// status code and a body that can be deserialized to an "expected" +// error response. These errors usually happen due to a client error +// and result in 4xx response statuses. The Clerk API responds with a +// familiar response body. +func TestBackendCall_ParseableError(t *testing.T) { + errorJSON := `{ + "clerk_trace_id": "trace-id", + "errors": [ + { + "code": "error-code", + "message": "error-message", + "long_message": "long-error-message", + "meta": { + "param_name": "param-name" + } + } + ] +}` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, err := w.Write([]byte(errorJSON)) + require.NoError(t, err) + })) + defer ts.Close() + + SetBackend(NewBackend(&BackendConfig{ + HTTPClient: ts.Client(), + URL: &ts.URL, + })) + resource := &testResource{} + err := GetBackend().Call(context.Background(), NewAPIRequest(http.MethodPost, "/v1/resources"), resource) + require.Error(t, err) + + // The error is an APIErrorResponse. We can assert on certain useful fields. + apiErr, ok := err.(*APIErrorResponse) + require.True(t, ok) + assert.Equal(t, http.StatusUnprocessableEntity, apiErr.HTTPStatusCode) + assert.Equal(t, "trace-id", apiErr.TraceID) + + // The response errors have been deserialized correctly. + require.Equal(t, 1, len(apiErr.Errors)) + assert.Equal(t, "error-code", apiErr.Errors[0].Code) + assert.Equal(t, "error-message", apiErr.Errors[0].Message) + assert.Equal(t, "long-error-message", apiErr.Errors[0].LongMessage) + assert.JSONEq(t, `{"param_name":"param-name"}`, string(apiErr.Errors[0].Meta)) + + // We've stored the raw response as well. + require.NotNil(t, apiErr.Response) + assert.JSONEq(t, errorJSON, string(apiErr.Response.RawJSON)) +} + +// TestBackendCall_ParseableError tests responses with a non-successful +// status code and a body that can be deserialized to an unexpected +// error response. This might happen when the Clerk API encounters an +// unexpected server error and usually results in 5xx status codes. +func TestBackendCall_NonParseableError(t *testing.T) { + errorResponse := `{invalid}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(errorResponse)) + require.NoError(t, err) + })) + defer ts.Close() + + SetBackend(NewBackend(&BackendConfig{ + HTTPClient: ts.Client(), + URL: &ts.URL, + })) + resource := &testResource{} + err := GetBackend().Call(context.Background(), NewAPIRequest(http.MethodPost, "/v1/resources"), resource) + require.Error(t, err) + // The raw error is returned since we cannot unmarshal it to a + // familiar API error response. + assert.Equal(t, errorResponse, err.Error()) +} diff --git a/go.mod b/go.mod index 9b8318de..d8d72076 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/clerk/clerk-sdk-go/v2 go 1.16 + +require github.com/stretchr/testify v1.7.5 diff --git a/go.sum b/go.sum index e69de29b..f59e5c0a 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,15 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= +github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=