From a9e49906e0bc81ee5e8e82c6746760498c7a9450 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:33:23 -0600 Subject: [PATCH] feat: initialize client core and ping endpoint establishes the foundational architecture for the Onspring Go SDK. changes included: - add `Client` struct and `NewClient` constructor with authentication. - implement functional options pattern (WithHTTPClient, WithBaseURL, etc.). - add `OnspringAPIError` type for handling non-2xx responses. - implement internal request construction and execution logic. - add `Ping` service for API health check verification. - add comprehensive unit tests and mock server infrastructure. --- client.go | 165 ++++++++++++++++++++++++++++++++++++++++++ client_export_test.go | 19 +++++ client_test.go | 53 ++++++++++++++ error.go | 24 ++++++ error_test.go | 40 ++++++++++ infra_test.go | 34 +++++++++ option.go | 30 ++++++++ option_test.go | 54 ++++++++++++++ ping.go | 46 ++++++++++++ ping_test.go | 103 ++++++++++++++++++++++++++ 10 files changed, 568 insertions(+) create mode 100644 client.go create mode 100644 client_export_test.go create mode 100644 client_test.go create mode 100644 error.go create mode 100644 error_test.go create mode 100644 infra_test.go create mode 100644 option.go create mode 100644 option_test.go create mode 100644 ping.go create mode 100644 ping_test.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..d43da08 --- /dev/null +++ b/client.go @@ -0,0 +1,165 @@ +// Package onspring provides a Go SDK for interacting with the Onspring API. +// It offers a type-safe, idiomatic Go interface for making API requests +// to the Onspring platform. +package onspring + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + // defaultBaseURL is the default base URL for the Onspring API. + defaultBaseURL = "https://api.onspring.com" + // defaultTimeout is the default HTTP client timeout duration. + defaultTimeout = 120 * time.Second + // defaultAPIKeyHeader is the HTTP header name used for API key authentication. + defaultAPIKeyHeader = "x-api-key" + // defaultAPIVersionHeader is the HTTP header name used to specify the API version. + defaultAPIVersionHeader = "x-api-version" + // defaultAPIVersion is the default Onspring API version to use. + defaultAPIVersion = "2.0" +) + +// Client is the main client for interacting with the Onspring API. +// It manages HTTP communication, authentication, and API versioning. +// All API endpoints are accessed through this client. +type Client struct { + // httpClient is the underlying HTTP client used to make requests. + httpClient *http.Client + // baseURL is the base URL for the Onspring API. + baseURL string + // apiKey is the API key used for authentication. + apiKey string + // apiVersion is the API version to use for requests. + apiVersion string + + // Ping provides access to the ping endpoint for health checks. + Ping *PingEndpoint +} + +// NewClient creates a new Onspring API client with the provided API key. +// It initializes the client with default settings including a 100-second timeout, +// the production API base URL, and API version 2.0. +// +// Optional configuration can be provided using Option functions such as +// WithHTTPClient, WithBaseURL, and WithAPIVersion. +// +// Parameters: +// - apiKey: The API key for authenticating with the Onspring API +// - opts: Optional configuration functions to customize the client +// +// Returns: +// - *Client: A configured Onspring API client ready to make requests +// +// Example: +// +// client := onspring.NewClient("your-api-key") +// client := onspring.NewClient("your-api-key", onspring.WithHTTPClient(customHTTPClient)) +func NewClient(apiKey string, opts ...Option) *Client { + c := &Client{ + httpClient: &http.Client{Timeout: defaultTimeout}, + baseURL: defaultBaseURL, + apiKey: apiKey, + apiVersion: defaultAPIVersion, + } + + for _, opt := range opts { + opt(c) + } + + c.Ping = &PingEndpoint{client: c} + + return c +} + +// do executes an HTTP request and handles the response. +// It performs the actual HTTP call using the configured HTTP client, +// checks the response status code, and handles any API errors. +// +// Parameters: +// - req: The HTTP request to execute +// +// Returns: +// - error: nil if the request succeeds, or an error if the request fails +// or returns a non-2xx status code +func (c *Client) do(req *http.Request) error { + resp, err := c.httpClient.Do(req) + + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.handleAPIError(resp) + } + + return nil +} + +// handleAPIError processes error responses from the Onspring API. +// It attempts to decode the error message from the response body. +// If decoding fails, it falls back to using the HTTP status text. +// +// Parameters: +// - resp: The HTTP response containing the error +// +// Returns: +// - error: An OnspringAPIError with the status code and error message +func (c *Client) handleAPIError(resp *http.Response) error { + var errBody struct { + Message string `json:"message"` + } + + decodeErr := json.NewDecoder(resp.Body).Decode(&errBody) + + if decodeErr != nil { + errBody.Message = http.StatusText(resp.StatusCode) + } + + return &OnspringAPIError{ + StatusCode: resp.StatusCode, + Message: errBody.Message, + } +} + +// newRequest creates a new HTTP request for the Onspring API. +// It constructs the full URL, sets required authentication headers, +// and prepares the request with the provided context. +// +// Parameters: +// - ctx: The context for the request +// - method: The HTTP method +// - path: The API endpoint path +// - body: The request body +// +// Returns: +// - *http.Request: The prepared HTTP request +// - error: An error if the context is nil or request creation fails +func (c *Client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) { + if ctx == nil { + return nil, fmt.Errorf("context must not be nil") + } + + fullURL := fmt.Sprintf("%s/%s", strings.TrimRight(c.baseURL, "/"), strings.TrimLeft(path, "/")) + + var bodyReader io.Reader + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set(defaultAPIKeyHeader, c.apiKey) + req.Header.Set(defaultAPIVersionHeader, c.apiVersion) + + return req, nil +} diff --git a/client_export_test.go b/client_export_test.go new file mode 100644 index 0000000..6130ab8 --- /dev/null +++ b/client_export_test.go @@ -0,0 +1,19 @@ +package onspring + +import "net/http" + +func (c *Client) HTTPClient() *http.Client { + return c.httpClient +} + +func (c *Client) BaseURL() string { + return c.baseURL +} + +func (c *Client) APIKey() string { + return c.apiKey +} + +func (c *Client) APIVersion() string { + return c.apiVersion +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..5dc695e --- /dev/null +++ b/client_test.go @@ -0,0 +1,53 @@ +package onspring_test + +import ( + "net/http" + "testing" + "time" + + "github.com/StevanFreeborn/onspring-api-sdk-go" +) + +func TestNewClient(t *testing.T) { + t.Run("it should create client with default settings", func(t *testing.T) { + apiKey := "test-api-key" + client := onspring.NewClient(apiKey) + + if client.APIKey() != apiKey { + t.Errorf("Expected apiKey %s, got %s", apiKey, client.APIKey()) + } + + if client.BaseURL() != "https://api.onspring.com" { + t.Errorf("Expected default baseURL, got %s", client.BaseURL()) + } + + if client.HTTPClient().Timeout != 120*time.Second { + t.Errorf("Expected default timeout, got %v", client.HTTPClient().Timeout) + } + + }) + + t.Run("it should create client with custom settings", func(t *testing.T) { + apiKey := "test-api-key" + customURL := "https://custom.onspring.com" + customHTTPClient := &http.Client{Timeout: 50 * time.Second} + + client := onspring.NewClient( + apiKey, + onspring.WithBaseURL(customURL), + onspring.WithHTTPClient(customHTTPClient), + ) + + if client.APIKey() != apiKey { + t.Errorf("Expected apiKey %s, got %s", apiKey, client.APIKey()) + } + + if client.BaseURL() != customURL { + t.Errorf("Expected baseURL %s, got %s", customURL, client.BaseURL()) + } + + if client.HTTPClient() != customHTTPClient { + t.Errorf("Expected custom HTTP client, got %v", client.HTTPClient()) + } + }) +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..45edf4b --- /dev/null +++ b/error.go @@ -0,0 +1,24 @@ +package onspring + +import ( + "fmt" +) + +// OnspringAPIError represents an error returned by the Onspring API. +// It contains the HTTP status code and the error message from the API response. +// This error type implements the error interface. +type OnspringAPIError struct { + // StatusCode is the HTTP status code returned by the API. + StatusCode int + // Message is the error message returned by the API or the HTTP status text. + Message string +} + +// Error returns a formatted error string containing the status code and message. +// It implements the error interface for OnspringAPIError. +// +// Returns: +// - string: A formatted error message in the format "onspring api error: status={code} message={message}" +func (e *OnspringAPIError) Error() string { + return fmt.Sprintf("onspring api error: status=%d message=%s", e.StatusCode, e.Message) +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..3ceeadf --- /dev/null +++ b/error_test.go @@ -0,0 +1,40 @@ +package onspring_test + +import ( + "testing" + + "github.com/StevanFreeborn/onspring-api-sdk-go" +) + +func TestOnspringAPIError(t *testing.T) { + t.Run("it should create OnspringAPIError instance", func(t *testing.T) { + statusCode := 500 + message := "Internal Server Error" + + err := &onspring.OnspringAPIError{ + StatusCode: statusCode, + Message: message, + } + + if err.StatusCode != statusCode { + t.Errorf("Expected status code %d, got %d", statusCode, err.StatusCode) + } + + if err.Message != message { + t.Errorf("Expected message %s, got %s", message, err.Message) + } + }) + + t.Run("it should return formatted error message", func(t *testing.T) { + err := &onspring.OnspringAPIError{ + StatusCode: 404, + Message: "Not Found", + } + + expectedMessage := "onspring api error: status=404 message=Not Found" + + if err.Error() != expectedMessage { + t.Errorf("Expected error message %s, got %s", expectedMessage, err.Error()) + } + }) +} diff --git a/infra_test.go b/infra_test.go new file mode 100644 index 0000000..ccf7c2a --- /dev/null +++ b/infra_test.go @@ -0,0 +1,34 @@ +package onspring_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/StevanFreeborn/onspring-api-sdk-go" +) + +func setupMockServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *onspring.Client) { + t.Helper() + + server := httptest.NewServer(handler) + + t.Cleanup(func() { + server.Close() + }) + + client := onspring.NewClient( + "test-key", + onspring.WithBaseURL(server.URL), + onspring.WithHTTPClient(server.Client()), + ) + + return server, client +} + +type ErrorTransport struct{} + +func (t *ErrorTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return nil, errors.New("simulated network connection failure") +} diff --git a/option.go b/option.go new file mode 100644 index 0000000..1a46134 --- /dev/null +++ b/option.go @@ -0,0 +1,30 @@ +package onspring + +import "net/http" + +// Option is a functional option for configuring a Client. +type Option func(*Client) + +// WithHTTPClient sets a custom HTTP client for the Onspring client. +// If not provided, the client will use http.DefaultClient. +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) { + c.httpClient = client + } +} + +// WithBaseURL sets a custom base URL for the Onspring API. +// This is useful for testing or when using a different Onspring environment. +func WithBaseURL(url string) Option { + return func(c *Client) { + c.baseURL = url + } +} + +// WithAPIVersion sets a custom API version for the Onspring client. +// If not provided, the client will use the default API version. +func WithAPIVersion(version string) Option { + return func(c *Client) { + c.apiVersion = version + } +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..2c1b1ef --- /dev/null +++ b/option_test.go @@ -0,0 +1,54 @@ +package onspring_test + +import ( + "net/http" + "testing" + "time" + + "github.com/StevanFreeborn/onspring-api-sdk-go" +) + +func TestWithHTTPClient(t *testing.T) { + t.Run("it should set a custom HTTP client on the Onsring client", func(t *testing.T) { + customHTTPClient := &http.Client{Timeout: 10 * time.Second} + + clientWithCustomHTTP := onspring.NewClient( + "test-api-key", + onspring.WithHTTPClient(customHTTPClient), + ) + + if clientWithCustomHTTP.HTTPClient() != customHTTPClient { + t.Errorf("Expected custom HTTP client to be set") + } + }) +} + +func TestWithBaseURL(t *testing.T) { + t.Run("it should set a custom base URL on the Onsring client", func(t *testing.T) { + customBaseURL := "https://custom.onspring.com" + + clientWithCustomURL := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL(customBaseURL), + ) + + if clientWithCustomURL.BaseURL() != customBaseURL { + t.Errorf("Expected base URL to be %s, got %s", customBaseURL, clientWithCustomURL.BaseURL()) + } + }) +} + +func TestWithAPIVersion(t *testing.T) { + t.Run("it should set a custom API version on the Onsring client", func(t *testing.T) { + customVersion := "3.0" + + clientWithCustomVersion := onspring.NewClient( + "test-api-key", + onspring.WithAPIVersion(customVersion), + ) + + if clientWithCustomVersion.APIVersion() != customVersion { + t.Errorf("Expected API version to be %s, got %s", customVersion, clientWithCustomVersion.APIVersion()) + } + }) +} diff --git a/ping.go b/ping.go new file mode 100644 index 0000000..8163798 --- /dev/null +++ b/ping.go @@ -0,0 +1,46 @@ +package onspring + +import ( + "context" + "net/http" +) + +const ( + // pingPath is the API endpoint path for the ping health check. + pingPath = "/ping" +) + +// PingEndpoint provides access to the Onspring API ping endpoint. +// It can be used to verify API connectivity and authentication. +type PingEndpoint struct { + // client is the parent Client used to make API requests. + client *Client +} + +// Get performs a ping request to verify API connectivity. +// This is a simple health check that can be used to test if the API is +// reachable. +// +// Parameters: +// - ctx: The context for the request +// +// Returns: +// - error: nil if the ping succeeds, or an error if the request fails +// or authentication is invalid +// +// Example: +// +// client := onspring.NewClient("your-api-key") +// err := client.Ping.Get(context.Background()) +// if err != nil { +// log.Fatal("Ping failed:", err) +// } +func (p *PingEndpoint) Get(ctx context.Context) error { + req, err := p.client.newRequest(ctx, http.MethodGet, pingPath, nil) + + if err != nil { + return err + } + + return p.client.do(req) +} diff --git a/ping_test.go b/ping_test.go new file mode 100644 index 0000000..38afc99 --- /dev/null +++ b/ping_test.go @@ -0,0 +1,103 @@ +package onspring_test + +import ( + "context" + "net/http" + "testing" + + "github.com/StevanFreeborn/onspring-api-sdk-go" +) + +func TestGet(t *testing.T) { + t.Run("it should return an error if context is nil", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + err := client.Ping.Get(nil) + + if err == nil { + t.Errorf("Expected error for nil context, got nil") + } + }) + + t.Run("it should return an error if context is canceled", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + ctx, cancel := context.WithCancel(context.Background()) + + cancel() + + err := client.Ping.Get(ctx) + + if err == nil { + t.Errorf("Expected error for canceled context, got nil") + } + }) + + t.Run("it should return an error if encounters a network error", func(t *testing.T) { + client := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://invalid-url"), + onspring.WithHTTPClient(&http.Client{Transport: &ErrorTransport{}}), + ) + + err := client.Ping.Get(context.Background()) + + if err == nil { + t.Errorf("Expected network error, got nil") + } + }) + + t.Run("it should return an error if create a request fails", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + invalidClient := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://[::1]:namedport"), + onspring.WithHTTPClient(client.HTTPClient()), + ) + + err := invalidClient.Ping.Get(context.Background()) + + if err == nil { + t.Errorf("Expected request creation error, got nil") + } + }) + + t.Run("it should perform a GET request to the /ping endpoint and return no error if receives 200 status code", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("Expected GET method, got %s", r.Method) + } + + if r.URL.Path != "/ping" { + t.Errorf("Expected /ping endpoint, got %s", r.URL.Path) + } + + w.WriteHeader(http.StatusOK) + }) + + err := client.Ping.Get(context.Background()) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("it should return an error if the /ping endpoint returns a non-200 status code", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + err := client.Ping.Get(context.Background()) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) +}