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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user