From 1f6218416cfdb3770a12b873ea2d3baf7c53c3e4 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:38:47 -0600 Subject: [PATCH] feat: add Apps endpoint and query parameter support - add `Apps` endpoint to the `Client` struct and initialize it in `NewClient` - implement `doWithJsonResponse` helper to handle request execution and JSON decoding in one step - update `newRequest` to support passing and encoding query parameters via `net/url` - rename `Option` to `ClientOption` for better clarity in the `NewClient` signature - refactor `Ping` tests to use a more structured nested layout - remove `option.go` and `option_test.go` as part of the configuration refactor --- apps.go | 70 +++++++++++ apps_test.go | 131 ++++++++++++++++++++ client.go | 53 +++++++- option.go => clientOption.go | 10 +- option_test.go => clientOption_test.go | 0 ping.go | 2 +- ping_test.go | 162 +++++++++++++------------ 7 files changed, 339 insertions(+), 89 deletions(-) create mode 100644 apps.go create mode 100644 apps_test.go rename option.go => clientOption.go (70%) rename option_test.go => clientOption_test.go (100%) diff --git a/apps.go b/apps.go new file mode 100644 index 0000000..50d7805 --- /dev/null +++ b/apps.go @@ -0,0 +1,70 @@ +package onspring + +import ( + "context" + "net/http" + "strconv" +) + +const ( + appsPath = "/apps" +) + +type AppsEndpoint struct { + client *Client +} + +type PagingRequest struct { + pageSize int + pageNumber int +} + +func (pr *PagingRequest) ToParams() map[string]string { + return map[string]string{ + "pageSize": strconv.Itoa(pr.pageNumber), + "pageNumber": strconv.Itoa(pr.pageSize), + } +} + +type PagingOption func(*PagingRequest) + +type Page[T any] struct { + PageNumber int `json:"pageNumber"` + PageSize int `json:"pageSize"` + TotalPages int `json:"totalPages"` + TotalRecords int `json:"totalRecords"` + Items []T `json:"items"` +} + +type App struct { + Href string `json:"href"` + Id int `json:"id"` + Name string `json:"name"` +} + +func (p *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Page[App], error) { + pagingRequest := &PagingRequest{ + pageSize: 1, + pageNumber: 50, + } + + for _, opt := range pagingOpts { + opt(pagingRequest) + } + + req, requestCreationErr := p.client.newRequest(ctx, http.MethodGet, appsPath, pagingRequest.ToParams(), nil) + + var page Page[App] + + if requestCreationErr != nil { + return page, requestCreationErr + } + + responseErr := p.client.doWithJsonResponse(req, &page) + + if responseErr != nil { + return page, responseErr + } + + return page, nil +} diff --git a/apps_test.go b/apps_test.go new file mode 100644 index 0000000..b5f8ac7 --- /dev/null +++ b/apps_test.go @@ -0,0 +1,131 @@ +package onspring_test + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" + + "github.com/StevanFreeborn/onspring-api-sdk-go" +) + +func TestApps(t *testing.T) { + t.Run("Get", func(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) + }) + + var nilContext context.Context = nil + + _, err := client.Apps.Get(nilContext) + + 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(t.Context()) + + cancel() + + _, err := client.Apps.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.Apps.Get(t.Context()) + + 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.Apps.Get(t.Context()) + + if err == nil { + t.Errorf("Expected request creation error, got nil") + } + }) + + t.Run("it should perform a GET request to the /apps endpoint and return page of apps if receives 200 status code", func(t *testing.T) { + expectedPage := onspring.Page[onspring.App]{ + TotalPages: 1, + TotalRecords: 1, + PageNumber: 1, + PageSize: 1, + Items: []onspring.App{ + { + Href: "https://test.com", + Id: 1, + Name: "App", + }, + }, + } + + _, 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 != "/apps" { + t.Errorf("Expected /apps endpoint, got %s", r.URL.Path) + } + + jsonData, _ := json.Marshal(expectedPage) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + }) + + page, err := client.Apps.Get(t.Context()) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if !reflect.DeepEqual(expectedPage, page) { + t.Errorf("Expected %v but got %v", expectedPage, page) + } + }) + + t.Run("it should return an error if the /apps 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.Apps.Get(t.Context()) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) + }) +} diff --git a/client.go b/client.go index 480abf2..0206f3c 100644 --- a/client.go +++ b/client.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -41,6 +42,8 @@ type Client struct { // Ping provides access to the ping endpoint for health checks. Ping *PingEndpoint + // Apps provides access to the apps within an Onspring instance. + Apps *AppsEndpoint } // NewClient creates a new Onspring API client with the provided API key. @@ -61,7 +64,7 @@ type Client struct { // // client := onspring.NewClient("your-api-key") // client := onspring.NewClient("your-api-key", onspring.WithHTTPClient(customHTTPClient)) -func NewClient(apiKey string, opts ...Option) *Client { +func NewClient(apiKey string, opts ...ClientOption) *Client { c := &Client{ httpClient: &http.Client{Timeout: defaultTimeout}, baseURL: defaultBaseURL, @@ -74,6 +77,7 @@ func NewClient(apiKey string, opts ...Option) *Client { } c.Ping = &PingEndpoint{client: c} + c.Apps = &AppsEndpoint{client: c} return c } @@ -106,6 +110,35 @@ func (c *Client) do(req *http.Request) error { return nil } +// doWithJsonResponse executes an HTTP request and decodes the JSON response. +// It performs the HTTP call, checks the response status code, and decodes +// the JSON response body into the provided variable. +// +// Parameters: +// - req: The HTTP request to execute +// - v: A pointer to the variable where the decoded JSON response will be stored +// +// Returns: +// - error: nil if the request and decoding succeed, or an error if the request fails, +// returns a non-2xx status code, or if JSON decoding fails +func (c *Client) doWithJsonResponse(req *http.Request, v any) error { + resp, err := c.httpClient.Do(req) + + if err != nil { + return err + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.handleAPIError(resp) + } + + return json.NewDecoder(resp.Body).Decode(v) +} + // 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. @@ -140,21 +173,35 @@ func (c *Client) handleAPIError(resp *http.Response) error { // - ctx: The context for the request // - method: The HTTP method // - path: The API endpoint path +// - queryParams: The query parameters for the request // - 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, _ any) (*http.Request, error) { +func (c *Client) newRequest(ctx context.Context, method, path string, queryParams map[string]string, _ 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, "/")) + validUrl, urlParsingError := url.Parse(fullURL) + + if urlParsingError != nil { + return nil, fmt.Errorf("failed to parse the request url: %w", urlParsingError) + } + + q := validUrl.Query() + + for key, value := range queryParams { + q.Add(key, value) + } + + validUrl.RawQuery = q.Encode() var bodyReader io.Reader - req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + req, err := http.NewRequestWithContext(ctx, method, validUrl.String(), bodyReader) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) diff --git a/option.go b/clientOption.go similarity index 70% rename from option.go rename to clientOption.go index 1a46134..727c00f 100644 --- a/option.go +++ b/clientOption.go @@ -2,12 +2,12 @@ package onspring import "net/http" -// Option is a functional option for configuring a Client. -type Option func(*Client) +// ClientOption is a functional option for configuring a Client. +type ClientOption 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 { +func WithHTTPClient(client *http.Client) ClientOption { return func(c *Client) { c.httpClient = client } @@ -15,7 +15,7 @@ func WithHTTPClient(client *http.Client) Option { // 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 { +func WithBaseURL(url string) ClientOption { return func(c *Client) { c.baseURL = url } @@ -23,7 +23,7 @@ func WithBaseURL(url string) Option { // 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 { +func WithAPIVersion(version string) ClientOption { return func(c *Client) { c.apiVersion = version } diff --git a/option_test.go b/clientOption_test.go similarity index 100% rename from option_test.go rename to clientOption_test.go diff --git a/ping.go b/ping.go index 8163798..dca4725 100644 --- a/ping.go +++ b/ping.go @@ -36,7 +36,7 @@ type PingEndpoint struct { // log.Fatal("Ping failed:", err) // } func (p *PingEndpoint) Get(ctx context.Context) error { - req, err := p.client.newRequest(ctx, http.MethodGet, pingPath, nil) + req, err := p.client.newRequest(ctx, http.MethodGet, pingPath, nil, nil) if err != nil { return err diff --git a/ping_test.go b/ping_test.go index 62ef142..1068228 100644 --- a/ping_test.go +++ b/ping_test.go @@ -8,98 +8,100 @@ import ( "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) - }) +func TestPing(t *testing.T) { + t.Run("Get", func(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) + }) - var nilContext context.Context = nil + var nilContext context.Context = nil - err := client.Ping.Get(nilContext) + err := client.Ping.Get(nilContext) - 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(t.Context()) - - 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(t.Context()) - - 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(t.Context()) - - 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 err == nil { + t.Errorf("Expected error for nil context, got nil") } + }) - if r.URL.Path != "/ping" { - t.Errorf("Expected /ping endpoint, got %s", r.URL.Path) + 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(t.Context()) + + cancel() + + err := client.Ping.Get(ctx) + + if err == nil { + t.Errorf("Expected error for canceled context, got nil") } - - w.WriteHeader(http.StatusOK) }) - err := client.Ping.Get(t.Context()) + 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{}}), + ) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - }) + err := client.Ping.Get(t.Context()) - 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) + if err == nil { + t.Errorf("Expected network error, got nil") + } }) - err := client.Ping.Get(t.Context()) + 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) + }) - if err == nil { - t.Errorf("Expected error, got nil") - } + invalidClient := onspring.NewClient( + "test-api-key", + onspring.WithBaseURL("http://[::1]:namedport"), + onspring.WithHTTPClient(client.HTTPClient()), + ) + + err := invalidClient.Ping.Get(t.Context()) + + 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(t.Context()) + + 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(t.Context()) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) }) }