From ecebb51bad4b441491e536e1a1d5153fbef16cd8 Mon Sep 17 00:00:00 2001 From: Giannis Katsanos Date: Mon, 29 Jan 2024 22:12:59 +0200 Subject: [PATCH] feat: Base for implementing API operations Added core types, methods and interfaces for building API operations. The package provides a basic Backend that can communicate with the Clerk API. The Backend can handle APIRequest requests and APIResponse responses. Types are also provided for API resources and parameters, as well as expected errors. --- clerk.go | 360 ++++++++++++++++++++++++++++++++++++++++++++++++++ clerk_test.go | 343 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 15 +++ 4 files changed, 720 insertions(+) create mode 100644 clerk.go create mode 100644 clerk_test.go 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=