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.
This commit is contained in:
Stevan Freeborn
2025-12-05 21:33:23 -06:00
parent 3098779596
commit a9e49906e0
10 changed files with 568 additions and 0 deletions
+165
View File
@@ -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
}
+19
View File
@@ -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
}
+53
View File
@@ -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())
}
})
}
+24
View File
@@ -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)
}
+40
View File
@@ -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())
}
})
}
+34
View File
@@ -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")
}
+30
View File
@@ -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
}
}
+54
View File
@@ -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())
}
})
}
+46
View File
@@ -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)
}
+103
View File
@@ -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")
}
})
}