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] 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 } }