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 01/11] 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") + } + }) }) } From bfa1b06a839028e5ba08f826ff14378d1aee9473 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:46:37 -0600 Subject: [PATCH 02/11] refactor: remove paging types from apps endpoint - remove `PagingRequest`, `PagingOption`, and `Page` struct from `apps.go` - remove unused `strconv` import --- apps.go | 23 ----------------------- page.go | 9 +++++++++ pagingRequest.go | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 page.go create mode 100644 pagingRequest.go diff --git a/apps.go b/apps.go index 50d7805..9d9fb95 100644 --- a/apps.go +++ b/apps.go @@ -3,7 +3,6 @@ package onspring import ( "context" "net/http" - "strconv" ) const ( @@ -14,28 +13,6 @@ 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"` diff --git a/page.go b/page.go new file mode 100644 index 0000000..644a49b --- /dev/null +++ b/page.go @@ -0,0 +1,9 @@ +package onspring + +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"` +} diff --git a/pagingRequest.go b/pagingRequest.go new file mode 100644 index 0000000..67924f4 --- /dev/null +++ b/pagingRequest.go @@ -0,0 +1,17 @@ +package onspring + +import "strconv" + +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) From 126b027a811572f740bba164b5c71a1ee630f425 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:03:59 -0600 Subject: [PATCH 03/11] fix: correct paging parameter logic and add paging options - fix bug where `pageNumber` and `pageSize` were swapped during default initialization in `apps.go`. - fix bug in `pagingRequest.go` where `ToParams` mapped keys to the incorrect struct fields. - add `ForPageNumber` and `WithPageSize` functional options to allow custom paging configuration. - enhance `Apps` endpoint tests to verify that `pageNumber` and `pageSize` query parameters are correctly passed to the API. - add test case for verifying non-default paging options. --- apps.go | 4 ++-- apps_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ pagingRequest.go | 18 +++++++++++++++--- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/apps.go b/apps.go index 9d9fb95..7ccbccc 100644 --- a/apps.go +++ b/apps.go @@ -21,8 +21,8 @@ type App struct { func (p *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Page[App], error) { pagingRequest := &PagingRequest{ - pageSize: 1, - pageNumber: 50, + pageNumber: 1, + pageSize: 50, } for _, opt := range pagingOpts { diff --git a/apps_test.go b/apps_test.go index b5f8ac7..1324ea9 100644 --- a/apps_test.go +++ b/apps_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "reflect" + "strconv" "testing" "github.com/StevanFreeborn/onspring-api-sdk-go" @@ -75,6 +76,9 @@ func TestApps(t *testing.T) { }) 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) { + expectedPageNumber := 1 + expectedPageSize := 50 + expectedPage := onspring.Page[onspring.App]{ TotalPages: 1, TotalRecords: 1, @@ -98,6 +102,17 @@ func TestApps(t *testing.T) { t.Errorf("Expected /apps endpoint, got %s", r.URL.Path) } + pageNumber := r.URL.Query().Get("pageNumber") + pageSize := r.URL.Query().Get("pageSize") + + if pageNumber != strconv.Itoa(expectedPageNumber) { + t.Errorf("Expected query param pageNumber to be %d but got %s", expectedPageNumber, pageNumber) + } + + if pageSize != strconv.Itoa(expectedPageSize) { + t.Errorf("Expected query param pageSize to be %d but got %s", expectedPageSize, pageSize) + } + jsonData, _ := json.Marshal(expectedPage) w.WriteHeader(http.StatusOK) @@ -116,6 +131,40 @@ func TestApps(t *testing.T) { } }) + t.Run("it should perform a GET request to the /apps endpoint with non-default paging information when provided", func(t *testing.T) { + expectedPageNumber := 2 + expectedPageSize := 1 + + _, 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) + } + + pageNumber := r.URL.Query().Get("pageNumber") + pageSize := r.URL.Query().Get("pageSize") + + if pageNumber != strconv.Itoa(expectedPageNumber) { + t.Errorf("Expected query param pageNumber to be %d but got %s", expectedPageNumber, pageNumber) + } + + if pageSize != strconv.Itoa(expectedPageSize) { + t.Errorf("Expected query param pageSize to be %d but got %s", expectedPageSize, pageSize) + } + + w.WriteHeader(http.StatusOK) + }) + + client.Apps.Get( + t.Context(), + onspring.ForPageNumber(expectedPageNumber), + onspring.WithPageSize(expectedPageSize), + ) + }) + 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) diff --git a/pagingRequest.go b/pagingRequest.go index 67924f4..6bfbf94 100644 --- a/pagingRequest.go +++ b/pagingRequest.go @@ -3,15 +3,27 @@ package onspring import "strconv" type PagingRequest struct { - pageSize int pageNumber int + pageSize int } func (pr *PagingRequest) ToParams() map[string]string { return map[string]string{ - "pageSize": strconv.Itoa(pr.pageNumber), - "pageNumber": strconv.Itoa(pr.pageSize), + "pageNumber": strconv.Itoa(pr.pageNumber), + "pageSize": strconv.Itoa(pr.pageSize), } } type PagingOption func(*PagingRequest) + +func ForPageNumber(pageNumber int) PagingOption { + return func(pr *PagingRequest) { + pr.pageNumber = pageNumber + } +} + +func WithPageSize(pageSize int) PagingOption { + return func(pr *PagingRequest) { + pr.pageSize = pageSize + } +} From c00998eed624aa77956d55cb9f59c9694e421bee Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:10:41 -0600 Subject: [PATCH 04/11] docs: add documentation comments for apps and paging types - add doc comments to `AppsEndpoint`, `App` struct, and the `Get` method in `apps.go`. - add doc comments to the `Page` struct in `page.go`. - add doc comments to `PagingRequest`, `PagingOption`, and paging helper functions in `pagingRequest.go`. --- apps.go | 11 +++++++++++ page.go | 1 + pagingRequest.go | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/apps.go b/apps.go index 7ccbccc..2b32233 100644 --- a/apps.go +++ b/apps.go @@ -9,16 +9,27 @@ const ( appsPath = "/apps" ) +// AppsEndpoint provides access to apps in an Onspring instance. type AppsEndpoint struct { client *Client } +// App represents an Onspring app type App struct { Href string `json:"href"` Id int `json:"id"` Name string `json:"name"` } +// Get retrieves a paginated list of apps from the Onspring API. +// +// Parameters: +// - ctx: The context for the request +// - pagingOpts: Optional paging configuration functions (e.g., ForPageNumber, WithPageSize) +// +// Returns: +// - Page[App]: A page of apps with pagination metadata +// - error: An error if the request fails func (p *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Page[App], error) { pagingRequest := &PagingRequest{ pageNumber: 1, diff --git a/page.go b/page.go index 644a49b..1a3efeb 100644 --- a/page.go +++ b/page.go @@ -1,5 +1,6 @@ package onspring +// Page represents a paginated response from the Onspring API. type Page[T any] struct { PageNumber int `json:"pageNumber"` PageSize int `json:"pageSize"` diff --git a/pagingRequest.go b/pagingRequest.go index 6bfbf94..d75e568 100644 --- a/pagingRequest.go +++ b/pagingRequest.go @@ -2,11 +2,13 @@ package onspring import "strconv" +// PagingRequest contains pagination parameters for API requests. type PagingRequest struct { pageNumber int pageSize int } +// ToParams converts the paging request to a map of query parameters. func (pr *PagingRequest) ToParams() map[string]string { return map[string]string{ "pageNumber": strconv.Itoa(pr.pageNumber), @@ -14,14 +16,17 @@ func (pr *PagingRequest) ToParams() map[string]string { } } +// PagingOption is a function that modifies a PagingRequest. type PagingOption func(*PagingRequest) +// ForPageNumber sets the page number for a paging request. func ForPageNumber(pageNumber int) PagingOption { return func(pr *PagingRequest) { pr.pageNumber = pageNumber } } +// WithPageSize sets the page size for a paging request. func WithPageSize(pageSize int) PagingOption { return func(pr *PagingRequest) { pr.pageSize = pageSize From 02351d89db1d47908866cb4f7087800942c8be05 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:15:12 -0500 Subject: [PATCH 05/11] feat: finish implementing GetAll and GetBatch for apps endpoint --- README.md | 2 +- apps.go | 99 +++++++++++-- apps_test.go | 359 +++++++++++++++++++++++++++++++++++++++++++++++ pagingRequest.go | 14 +- 4 files changed, 455 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 983b213..2729d51 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This SDK was developed independently using Onspring's existing C# SDK, the Onspr ### Go -Requires use of [Go](https://golang.org/dl/) version 1.18 or higher. +Requires use of [Go](https://golang.org/dl/) version 1.23 or higher. ## Installation diff --git a/apps.go b/apps.go index 2b32233..e6c6251 100644 --- a/apps.go +++ b/apps.go @@ -2,11 +2,13 @@ package onspring import ( "context" + "iter" "net/http" ) const ( - appsPath = "/apps" + appsPath = "/apps" + appsBatchPath = "/apps/batch-get" ) // AppsEndpoint provides access to apps in an Onspring instance. @@ -21,6 +23,12 @@ type App struct { Name string `json:"name"` } +// AppBatch represents a batch of Onspring apps +type AppBatch struct { + Count int `json:"count"` + Items []App `json:"items"` +} + // Get retrieves a paginated list of apps from the Onspring API. // // Parameters: @@ -30,17 +38,10 @@ type App struct { // Returns: // - Page[App]: A page of apps with pagination metadata // - error: An error if the request fails -func (p *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Page[App], error) { - pagingRequest := &PagingRequest{ - pageNumber: 1, - pageSize: 50, - } +func (a *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Page[App], error) { + pagingRequest := createPagingRequest(pagingOpts) - for _, opt := range pagingOpts { - opt(pagingRequest) - } - - req, requestCreationErr := p.client.newRequest(ctx, http.MethodGet, appsPath, pagingRequest.ToParams(), nil) + req, requestCreationErr := a.client.newRequest(ctx, http.MethodGet, appsPath, pagingRequest.ToParams(), nil) var page Page[App] @@ -48,7 +49,7 @@ func (p *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Pag return page, requestCreationErr } - responseErr := p.client.doWithJsonResponse(req, &page) + responseErr := a.client.doWithJsonResponse(req, &page) if responseErr != nil { return page, responseErr @@ -56,3 +57,77 @@ func (p *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Pag return page, nil } + +// GetAll returns an iterator that yields all Apps across all pages. +// It automatically handles pagination by making sequential calls to Get +// until all items have been retrieved or the caller stops the iteration. +// +// The iterator yields each *App and any error encountered during fetching. +// If an error occurs during a page request, the error is yielded and +// iteration terminates. +// +// Parameters: +// - ctx: The context for the request +// - pagingOpts: Optional paging configuration functions (e.g., ForPageNumber, WithPageSize) +// +// Returns: +// - iter.Seq2[App, error]: An iterator yielding: +// - App: The individual application record. +// - error: An error if a specific page request fails during iteration. +func (a *AppsEndpoint) GetAll(ctx context.Context, pagingOpts ...PagingOption) iter.Seq2[App, error] { + return func(yield func(App, error) bool) { + pagingRequest := createPagingRequest(pagingOpts) + + for { + page, err := a.Get(ctx, ForPageNumber(pagingRequest.PageNumber), WithPageSize(pagingRequest.PageSize)) + + if err != nil { + yield(App{}, err) + return + } + + for _, item := range page.Items { + if !yield(item, nil) { + return + } + } + + if page.TotalPages == page.PageNumber { + break + } + + pagingRequest.PageNumber++ + } + } +} + +func (a *AppsEndpoint) GetBatch(ctx context.Context, appIds []int) (AppBatch, error) { + req, requestCreationErr := a.client.newRequest(ctx, http.MethodPost, appsBatchPath, nil, appIds) + + var appBatch AppBatch + + if requestCreationErr != nil { + return appBatch, requestCreationErr + } + + responseErr := a.client.doWithJsonResponse(req, &appBatch) + + if responseErr != nil { + return appBatch, responseErr + } + + return appBatch, nil +} + +func createPagingRequest(pagingOpts []PagingOption) *PagingRequest { + pagingRequest := &PagingRequest{ + PageNumber: 1, + PageSize: 50, + } + + for _, opt := range pagingOpts { + opt(pagingRequest) + } + + return pagingRequest +} diff --git a/apps_test.go b/apps_test.go index 1324ea9..5674acd 100644 --- a/apps_test.go +++ b/apps_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "reflect" + "slices" "strconv" "testing" @@ -177,4 +178,362 @@ func TestApps(t *testing.T) { } }) }) + + t.Run("GetAll", func(t *testing.T) { + t.Run("it should return an error if fails to retrieve any pages of apps", func(t *testing.T) { + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + for _, err := range client.Apps.GetAll(t.Context()) { + if err == nil { + t.Errorf("Expected error, got nil") + } + } + }) + + t.Run("it should return all the apps from multiple pages", func(t *testing.T) { + expectedApps := []onspring.App{ + { + Href: "https://test.com", + Id: 1, + Name: "App", + }, + { + Href: "https://test.com", + Id: 2, + Name: "App", + }, + } + + pageOne := onspring.Page[onspring.App]{ + TotalPages: 2, + TotalRecords: 2, + PageNumber: 1, + PageSize: 1, + Items: []onspring.App{expectedApps[0]}, + } + + pageTwo := onspring.Page[onspring.App]{ + TotalPages: 2, + TotalRecords: 2, + PageNumber: 2, + PageSize: 1, + Items: []onspring.App{expectedApps[1]}, + } + + _, 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) + } + + pageNumber := r.URL.Query().Get("pageNumber") + + if pageNumber == "1" { + jsonData, _ := json.Marshal(pageOne) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + } + + if pageNumber == "2" { + jsonData, _ := json.Marshal(pageTwo) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + } + }) + + retrievedApps := []onspring.App{} + + for app, _ := range client.Apps.GetAll(t.Context()) { + retrievedApps = append(retrievedApps, app) + } + + if !slices.Equal(expectedApps, retrievedApps) { + t.Errorf("Expected %v but got %v", expectedApps, retrievedApps) + } + }) + + t.Run("it should return apps and errors if some pages fail and some succeed", func(t *testing.T) { + expectedApps := []onspring.App{ + { + Href: "https://test.com", + Id: 1, + Name: "App", + }, + } + + pageOne := onspring.Page[onspring.App]{ + TotalPages: 2, + TotalRecords: 2, + PageNumber: 1, + PageSize: 1, + Items: []onspring.App{expectedApps[0]}, + } + + _, 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) + } + + pageNumber := r.URL.Query().Get("pageNumber") + + if pageNumber == "1" { + jsonData, _ := json.Marshal(pageOne) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + } + + if pageNumber == "2" { + w.WriteHeader(http.StatusInternalServerError) + } + }) + + retrievedApps := []onspring.App{} + encounteredErrors := []error{} + + for app, err := range client.Apps.GetAll(t.Context()) { + if err != nil { + encounteredErrors = append(encounteredErrors, err) + } else { + retrievedApps = append(retrievedApps, app) + } + } + + if !slices.Equal(expectedApps, retrievedApps) { + t.Errorf("Expected %v but got %v", expectedApps, retrievedApps) + } + + if len(encounteredErrors) != 1 { + t.Errorf("Expected to receive one error, but received %d", len(encounteredErrors)) + } + }) + + t.Run("it should start paging from specified page number when given", func(t *testing.T) { + expectedApps := []onspring.App{ + { + Href: "https://test.com", + Id: 2, + Name: "App", + }, + } + + pageTwo := onspring.Page[onspring.App]{ + TotalPages: 2, + TotalRecords: 2, + PageNumber: 2, + PageSize: 1, + Items: []onspring.App{expectedApps[0]}, + } + + _, 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) + } + + pageNumber := r.URL.Query().Get("pageNumber") + + if pageNumber == "2" { + jsonData, _ := json.Marshal(pageTwo) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + } + }) + + retrievedApps := []onspring.App{} + + for app, _ := range client.Apps.GetAll(t.Context(), onspring.ForPageNumber(2)) { + retrievedApps = append(retrievedApps, app) + } + + if !slices.Equal(expectedApps, retrievedApps) { + t.Errorf("Expected %v but got %v", expectedApps, retrievedApps) + } + }) + + t.Run("it should retrieve pages using specified page size when given", func(t *testing.T) { + expectedApps := []onspring.App{ + { + Href: "https://test.com", + Id: 1, + Name: "App", + }, + { + Href: "https://test.com", + Id: 2, + Name: "App", + }, + } + + page := onspring.Page[onspring.App]{ + TotalPages: 1, + TotalRecords: 2, + PageNumber: 1, + PageSize: 2, + Items: expectedApps, + } + + _, 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) + } + + pageSize := r.URL.Query().Get("pageSize") + + if pageSize == "2" { + jsonData, _ := json.Marshal(page) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + } + }) + + retrievedApps := []onspring.App{} + + for app, _ := range client.Apps.GetAll(t.Context(), onspring.WithPageSize(2)) { + retrievedApps = append(retrievedApps, app) + } + + if !slices.Equal(expectedApps, retrievedApps) { + t.Errorf("Expected %v but got %v", expectedApps, retrievedApps) + } + }) + }) + + t.Run("GetBatch", 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.GetBatch(nilContext, []int{}) + + 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.GetBatch(ctx, []int{}) + + 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.GetBatch(t.Context(), []int{}) + + 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.GetBatch(t.Context(), []int{}) + + if err == nil { + t.Errorf("Expected request creation error, got nil") + } + }) + + t.Run("it should return an error if the /apps/batch-get 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.GetBatch(t.Context(), []int{}) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) + + t.Run("it should return a batch of apps when the /apps/batch-get endpoint returns a 200 status code", func(t *testing.T) { + apps := []onspring.App{ + { + Href: "https://test.com", + Id: 1, + Name: "App", + }, + } + + expectedBatch := onspring.AppBatch{ + Count: len(apps), + Items: apps, + } + + _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected POST method, got %s", r.Method) + } + + if r.URL.Path != "/apps/batch-get" { + t.Errorf("Expected /apps/batch-get endpoint, got %s", r.URL.Path) + } + + jsonData, _ := json.Marshal(expectedBatch) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + }) + + batch, _ := client.Apps.GetBatch(t.Context(), []int{apps[0].Id}) + + if !reflect.DeepEqual(expectedBatch, batch) { + t.Errorf("Expected %v but got %v", expectedBatch, batch) + } + }) + }) } diff --git a/pagingRequest.go b/pagingRequest.go index d75e568..cd45b9d 100644 --- a/pagingRequest.go +++ b/pagingRequest.go @@ -4,15 +4,17 @@ import "strconv" // PagingRequest contains pagination parameters for API requests. type PagingRequest struct { - pageNumber int - pageSize int + // The page number to retrieve + PageNumber int + // The size of pages to retrieve + PageSize int } // ToParams converts the paging request to a map of query parameters. func (pr *PagingRequest) ToParams() map[string]string { return map[string]string{ - "pageNumber": strconv.Itoa(pr.pageNumber), - "pageSize": strconv.Itoa(pr.pageSize), + "pageNumber": strconv.Itoa(pr.PageNumber), + "pageSize": strconv.Itoa(pr.PageSize), } } @@ -22,13 +24,13 @@ type PagingOption func(*PagingRequest) // ForPageNumber sets the page number for a paging request. func ForPageNumber(pageNumber int) PagingOption { return func(pr *PagingRequest) { - pr.pageNumber = pageNumber + pr.PageNumber = pageNumber } } // WithPageSize sets the page size for a paging request. func WithPageSize(pageSize int) PagingOption { return func(pr *PagingRequest) { - pr.pageSize = pageSize + pr.PageSize = pageSize } } From 6361e41cf35dbd9d9452d07ea600da911db9c225 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:30:35 -0500 Subject: [PATCH 06/11] refactor: correct method names --- apps.go | 21 +++++++++++++++------ apps_test.go | 42 +++++++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/apps.go b/apps.go index e6c6251..9887148 100644 --- a/apps.go +++ b/apps.go @@ -29,7 +29,7 @@ type AppBatch struct { Items []App `json:"items"` } -// Get retrieves a paginated list of apps from the Onspring API. +// List retrieves a paginated list of apps from the Onspring API. // // Parameters: // - ctx: The context for the request @@ -38,7 +38,7 @@ type AppBatch struct { // Returns: // - Page[App]: A page of apps with pagination metadata // - error: An error if the request fails -func (a *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Page[App], error) { +func (a *AppsEndpoint) List(ctx context.Context, pagingOpts ...PagingOption) (Page[App], error) { pagingRequest := createPagingRequest(pagingOpts) req, requestCreationErr := a.client.newRequest(ctx, http.MethodGet, appsPath, pagingRequest.ToParams(), nil) @@ -58,7 +58,7 @@ func (a *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Pag return page, nil } -// GetAll returns an iterator that yields all Apps across all pages. +// ListAll returns an iterator that yields all Apps across all pages. // It automatically handles pagination by making sequential calls to Get // until all items have been retrieved or the caller stops the iteration. // @@ -74,12 +74,12 @@ func (a *AppsEndpoint) Get(ctx context.Context, pagingOpts ...PagingOption) (Pag // - iter.Seq2[App, error]: An iterator yielding: // - App: The individual application record. // - error: An error if a specific page request fails during iteration. -func (a *AppsEndpoint) GetAll(ctx context.Context, pagingOpts ...PagingOption) iter.Seq2[App, error] { +func (a *AppsEndpoint) ListAll(ctx context.Context, pagingOpts ...PagingOption) iter.Seq2[App, error] { return func(yield func(App, error) bool) { pagingRequest := createPagingRequest(pagingOpts) for { - page, err := a.Get(ctx, ForPageNumber(pagingRequest.PageNumber), WithPageSize(pagingRequest.PageSize)) + page, err := a.List(ctx, ForPageNumber(pagingRequest.PageNumber), WithPageSize(pagingRequest.PageSize)) if err != nil { yield(App{}, err) @@ -101,7 +101,16 @@ func (a *AppsEndpoint) GetAll(ctx context.Context, pagingOpts ...PagingOption) i } } -func (a *AppsEndpoint) GetBatch(ctx context.Context, appIds []int) (AppBatch, error) { +// GetMany retrieves a batch of apps from the Onspring API. +// +// Parameters: +// - ctx: The context for the request +// - appIds: The ids of the apps to retrieve +// +// Returns: +// - AppBatch: A batch of apps +// - error: An error if the request fails +func (a *AppsEndpoint) GetMany(ctx context.Context, appIds []int) (AppBatch, error) { req, requestCreationErr := a.client.newRequest(ctx, http.MethodPost, appsBatchPath, nil, appIds) var appBatch AppBatch diff --git a/apps_test.go b/apps_test.go index 5674acd..8dacb23 100644 --- a/apps_test.go +++ b/apps_test.go @@ -13,7 +13,7 @@ import ( ) func TestApps(t *testing.T) { - t.Run("Get", func(t *testing.T) { + t.Run("List", 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) @@ -21,7 +21,7 @@ func TestApps(t *testing.T) { var nilContext context.Context = nil - _, err := client.Apps.Get(nilContext) + _, err := client.Apps.List(nilContext) if err == nil { t.Errorf("Expected error for nil context, got nil") @@ -37,7 +37,7 @@ func TestApps(t *testing.T) { cancel() - _, err := client.Apps.Get(ctx) + _, err := client.Apps.List(ctx) if err == nil { t.Errorf("Expected error for canceled context, got nil") @@ -51,7 +51,7 @@ func TestApps(t *testing.T) { onspring.WithHTTPClient(&http.Client{Transport: &ErrorTransport{}}), ) - _, err := client.Apps.Get(t.Context()) + _, err := client.Apps.List(t.Context()) if err == nil { t.Errorf("Expected network error, got nil") @@ -69,7 +69,7 @@ func TestApps(t *testing.T) { onspring.WithHTTPClient(client.HTTPClient()), ) - _, err := invalidClient.Apps.Get(t.Context()) + _, err := invalidClient.Apps.List(t.Context()) if err == nil { t.Errorf("Expected request creation error, got nil") @@ -121,7 +121,7 @@ func TestApps(t *testing.T) { w.Write(jsonData) }) - page, err := client.Apps.Get(t.Context()) + page, err := client.Apps.List(t.Context()) if err != nil { t.Errorf("Expected no error, got %v", err) @@ -159,7 +159,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) }) - client.Apps.Get( + client.Apps.List( t.Context(), onspring.ForPageNumber(expectedPageNumber), onspring.WithPageSize(expectedPageSize), @@ -171,7 +171,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) }) - _, err := client.Apps.Get(t.Context()) + _, err := client.Apps.List(t.Context()) if err == nil { t.Errorf("Expected error, got nil") @@ -179,13 +179,13 @@ func TestApps(t *testing.T) { }) }) - t.Run("GetAll", func(t *testing.T) { + t.Run("ListAll", func(t *testing.T) { t.Run("it should return an error if fails to retrieve any pages of apps", func(t *testing.T) { _, client := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) - for _, err := range client.Apps.GetAll(t.Context()) { + for _, err := range client.Apps.ListAll(t.Context()) { if err == nil { t.Errorf("Expected error, got nil") } @@ -252,7 +252,7 @@ func TestApps(t *testing.T) { retrievedApps := []onspring.App{} - for app, _ := range client.Apps.GetAll(t.Context()) { + for app, _ := range client.Apps.ListAll(t.Context()) { retrievedApps = append(retrievedApps, app) } @@ -305,7 +305,7 @@ func TestApps(t *testing.T) { retrievedApps := []onspring.App{} encounteredErrors := []error{} - for app, err := range client.Apps.GetAll(t.Context()) { + for app, err := range client.Apps.ListAll(t.Context()) { if err != nil { encounteredErrors = append(encounteredErrors, err) } else { @@ -361,7 +361,7 @@ func TestApps(t *testing.T) { retrievedApps := []onspring.App{} - for app, _ := range client.Apps.GetAll(t.Context(), onspring.ForPageNumber(2)) { + for app, _ := range client.Apps.ListAll(t.Context(), onspring.ForPageNumber(2)) { retrievedApps = append(retrievedApps, app) } @@ -414,7 +414,7 @@ func TestApps(t *testing.T) { retrievedApps := []onspring.App{} - for app, _ := range client.Apps.GetAll(t.Context(), onspring.WithPageSize(2)) { + for app, _ := range client.Apps.ListAll(t.Context(), onspring.WithPageSize(2)) { retrievedApps = append(retrievedApps, app) } @@ -424,7 +424,7 @@ func TestApps(t *testing.T) { }) }) - t.Run("GetBatch", func(t *testing.T) { + t.Run("GetMany", 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) @@ -432,7 +432,7 @@ func TestApps(t *testing.T) { var nilContext context.Context = nil - _, err := client.Apps.GetBatch(nilContext, []int{}) + _, err := client.Apps.GetMany(nilContext, []int{}) if err == nil { t.Errorf("Expected error for nil context, got nil") @@ -448,7 +448,7 @@ func TestApps(t *testing.T) { cancel() - _, err := client.Apps.GetBatch(ctx, []int{}) + _, err := client.Apps.GetMany(ctx, []int{}) if err == nil { t.Errorf("Expected error for canceled context, got nil") @@ -462,7 +462,7 @@ func TestApps(t *testing.T) { onspring.WithHTTPClient(&http.Client{Transport: &ErrorTransport{}}), ) - _, err := client.Apps.GetBatch(t.Context(), []int{}) + _, err := client.Apps.GetMany(t.Context(), []int{}) if err == nil { t.Errorf("Expected network error, got nil") @@ -480,7 +480,7 @@ func TestApps(t *testing.T) { onspring.WithHTTPClient(client.HTTPClient()), ) - _, err := invalidClient.Apps.GetBatch(t.Context(), []int{}) + _, err := invalidClient.Apps.GetMany(t.Context(), []int{}) if err == nil { t.Errorf("Expected request creation error, got nil") @@ -492,7 +492,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) }) - _, err := client.Apps.GetBatch(t.Context(), []int{}) + _, err := client.Apps.GetMany(t.Context(), []int{}) if err == nil { t.Errorf("Expected error, got nil") @@ -529,7 +529,7 @@ func TestApps(t *testing.T) { w.Write(jsonData) }) - batch, _ := client.Apps.GetBatch(t.Context(), []int{apps[0].Id}) + batch, _ := client.Apps.GetMany(t.Context(), []int{apps[0].Id}) if !reflect.DeepEqual(expectedBatch, batch) { t.Errorf("Expected %v but got %v", expectedBatch, batch) From a543de60dbac4eda50d7fc33e7bc7237812ce623 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:30:52 -0500 Subject: [PATCH 07/11] feat: add get by id method for apps endpoint --- apps.go | 39 +++++++++++++++--- apps_test.go | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/apps.go b/apps.go index 9887148..1752db4 100644 --- a/apps.go +++ b/apps.go @@ -2,13 +2,13 @@ package onspring import ( "context" + "fmt" "iter" "net/http" ) const ( - appsPath = "/apps" - appsBatchPath = "/apps/batch-get" + appsPath = "/apps" ) // AppsEndpoint provides access to apps in an Onspring instance. @@ -105,13 +105,14 @@ func (a *AppsEndpoint) ListAll(ctx context.Context, pagingOpts ...PagingOption) // // Parameters: // - ctx: The context for the request -// - appIds: The ids of the apps to retrieve +// - ids: The ids of the apps to retrieve // // Returns: // - AppBatch: A batch of apps // - error: An error if the request fails -func (a *AppsEndpoint) GetMany(ctx context.Context, appIds []int) (AppBatch, error) { - req, requestCreationErr := a.client.newRequest(ctx, http.MethodPost, appsBatchPath, nil, appIds) +func (a *AppsEndpoint) GetMany(ctx context.Context, ids []int) (AppBatch, error) { + path := fmt.Sprintf("%s/batch-get", appsPath) + req, requestCreationErr := a.client.newRequest(ctx, http.MethodPost, path, nil, ids) var appBatch AppBatch @@ -128,6 +129,34 @@ func (a *AppsEndpoint) GetMany(ctx context.Context, appIds []int) (AppBatch, err return appBatch, nil } +// Get retrieves an app from the Onspring API. +// +// Parameters: +// - ctx: The context for the request +// - id: The id of the app to retrieve +// +// Returns: +// - App: An app +// - error: An error if the request fails +func (a *AppsEndpoint) Get(ctx context.Context, id int) (App, error) { + path := fmt.Sprintf("%s/id/%d", appsPath, id) + req, requestCreationErr := a.client.newRequest(ctx, http.MethodGet, path, nil, nil) + + var app App + + if requestCreationErr != nil { + return app, requestCreationErr + } + + responseErr := a.client.doWithJsonResponse(req, &app) + + if responseErr != nil { + return app, responseErr + } + + return app, nil +} + func createPagingRequest(pagingOpts []PagingOption) *PagingRequest { pagingRequest := &PagingRequest{ PageNumber: 1, diff --git a/apps_test.go b/apps_test.go index 8dacb23..7edb9e2 100644 --- a/apps_test.go +++ b/apps_test.go @@ -3,6 +3,7 @@ package onspring_test import ( "context" "encoding/json" + "fmt" "net/http" "reflect" "slices" @@ -536,4 +537,112 @@ 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, 0) + + 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, 0) + + 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(), 0) + + 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(), 0) + + if err == nil { + t.Errorf("Expected request creation error, got nil") + } + }) + + t.Run("it should return an error if the /apps/id/:id 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(), 0) + + if err == nil { + t.Errorf("Expected error, got nil") + } + }) + + t.Run("it should return an app if the /apps/id/:id endpoint returns a 200 status code", func(t *testing.T) { + expectedApp := 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) + } + + expectedPath := fmt.Sprintf("/apps/id/%d", expectedApp.Id) + + if r.URL.Path != expectedPath { + t.Errorf("Expected %s endpoint, got %s", expectedPath, r.URL.Path) + } + + jsonData, _ := json.Marshal(expectedApp) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + }) + + app, _ := client.Apps.Get(t.Context(), expectedApp.Id) + + if !reflect.DeepEqual(expectedApp, app) { + t.Errorf("Expected %v but got %v", expectedApp, app) + } + }) + }) } From 2ee3506bb3eea5aae23dae84accd6cbeaf5e24ce Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:31:08 -0500 Subject: [PATCH 08/11] docs: add examples for apps endpoints to README.md --- README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2729d51..a3d26c4 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ You may wish to refer to the full [Onspring API documentation](https://software. ```go import ( - "fmt" - "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" + "fmt" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" ) client := onspring.NewClient("your-api-key") @@ -85,7 +85,64 @@ client := onspring.NewClient("your-api-key") err := client.Ping.Get(context.TODO()) if err == nil { - fmt.Println("Connection successful!") + fmt.Println("Connection successful!") } ``` +### Apps + +#### Get App by Id + +```go +import ( + "fmt" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" +) + +client := onspring.NewClient("your-api-key") + +app, err := client.Apps.Get(context.TODO(), 1) +``` + +#### Get Apps by Page + +##### Retrieve a single page + +```go +import ( + "fmt" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" +) + +client := onspring.NewClient("your-api-key") + +page, err := client.Apps.List(t.Context()) +``` + +##### Retrieve all pages + +```go +import ( + "fmt" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" +) + +client := onspring.NewClient("your-api-key") + +for app, err := range client.Apps.ListAll(t.Context()) { + // Do stuff +} +``` + +#### Get Apps by Batch + +```go +import ( + "fmt" + "github.com/StevanFreeborn/onspring-api-sdk-go/onspring" +) + +client := onspring.NewClient("your-api-key") + +batch, err := client.Apps.GetMany(t.Context(), []int{ 1 }) +``` From 23ab108dd5d59eef2d6ea2145dc252248ac48f40 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:35:15 -0500 Subject: [PATCH 09/11] chore: add git attributes file --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d020be8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.go text eol=lf + From bd3b84db32d47b1c1e257644978f3bdf1bdb7f93 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:42:12 -0500 Subject: [PATCH 10/11] tests: fix errcheck violations --- apps_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps_test.go b/apps_test.go index 7edb9e2..5416e59 100644 --- a/apps_test.go +++ b/apps_test.go @@ -119,7 +119,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) + _, _ = w.Write(jsonData) }) page, err := client.Apps.List(t.Context()) @@ -160,7 +160,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) }) - client.Apps.List( + _, _ = client.Apps.List( t.Context(), onspring.ForPageNumber(expectedPageNumber), onspring.WithPageSize(expectedPageSize), @@ -239,7 +239,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) + _, _ = w.Write(jsonData) } if pageNumber == "2" { @@ -247,7 +247,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) + _, _ = w.Write(jsonData) } }) @@ -295,7 +295,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) + _, _ = w.Write(jsonData) } if pageNumber == "2" { From e032d0927c6492a8f29cedb93b66ed13e470696d Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:45:57 -0500 Subject: [PATCH 11/11] tests: fix errcheck violations --- apps_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps_test.go b/apps_test.go index 5416e59..c5af97d 100644 --- a/apps_test.go +++ b/apps_test.go @@ -356,7 +356,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) + _, _ = w.Write(jsonData) } }) @@ -409,7 +409,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) + _, _ = w.Write(jsonData) } }) @@ -527,7 +527,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) + _, _ = w.Write(jsonData) }) batch, _ := client.Apps.GetMany(t.Context(), []int{apps[0].Id}) @@ -635,7 +635,7 @@ func TestApps(t *testing.T) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) + _, _ = w.Write(jsonData) }) app, _ := client.Apps.Get(t.Context(), expectedApp.Id)