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
This commit is contained in:
@@ -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
|
||||
}
|
||||
+131
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -8,7 +8,8 @@ import (
|
||||
"github.com/StevanFreeborn/onspring-api-sdk-go"
|
||||
)
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
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)
|
||||
@@ -102,4 +103,5 @@ func TestGet(t *testing.T) {
|
||||
t.Errorf("Expected error, got nil")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user