diff --git a/core/paginate/paginate.go b/core/paginate/paginate.go new file mode 100644 index 000000000..4dfaf8d1a --- /dev/null +++ b/core/paginate/paginate.go @@ -0,0 +1,189 @@ +// Package paginate provides lazy and eager pagination for list operations that +// follow AIP-158. +package paginate + +import ( + "fmt" + "iter" + "math" +) + +// Response defines the methods needed for pagination. The result of Request.Execute() must implement this interface. +type Response[Item any] interface { + GetItems() []Item + GetNextPageToken() string +} + +// Request defines the methods needed for pagination on te request side. +type Request[Self, Resp any] interface { + PageSize(pageSize int32) Self + PageToken(pageToken string) Self + Execute() (Resp, error) +} + +type Option func(*options) error + +type options struct { + pageSize int32 + limit *int + maxPages *int +} + +// WithPageSize sets the preferred number of items requested per page. The +// service may return fewer items. A value of zero does not override the page +// size already set on the supplied request. +func WithPageSize(pageSize int32) Option { + return func(o *options) error { + if pageSize < 0 { + return fmt.Errorf("page size must not be negative: %d", pageSize) + } + o.pageSize = pageSize + return nil + } +} + +// WithLimit sets the maximum number of items yielded across all pages. A zero +// limit performs no requests and yields no items. +func WithLimit(limit int) Option { + return func(o *options) error { + if limit < 0 { + return fmt.Errorf("limit must not be negative: %d", limit) + } + o.limit = &limit + return nil + } +} + +// WithMaxPages sets the maximum number of pages fetched. A zero maximum +// performs no requests. Reaching the maximum is not an error. +func WithMaxPages(maxPages int) Option { + return func(o *options) error { + if maxPages < 0 { + return fmt.Errorf("maximum pages must not be negative: %d", maxPages) + } + o.maxPages = &maxPages + return nil + } +} + +// Items returns a lazy iterator over the items of pageable ist operation. +// No request is made until the iterator is consumed. Each successful item is +// yielded with a nil error. If a request or option fails, the error is yielded +// once with the zero value of Item and iteration stops. +// +// Iteration also stops when the consumer returns false, the configured item or +// page limit is reached, or the service returns an empty next page token. +func Items[ + Item any, + Resp Response[Item], + Req Request[Req, Resp], +](request Req, opts ...Option) iter.Seq2[Item, error] { + return func(yield func(Item, error) bool) { + var zero Item + + cfg, err := applyOptions(opts) + if err != nil { + yield(zero, fmt.Errorf("paginate: invalid option: %w", err)) + return + } + if cfg.limit != nil && *cfg.limit == 0 { + return + } + if cfg.maxPages != nil && *cfg.maxPages == 0 { + return + } + + seenTokens := make(map[string]struct{}) + itemCount := 0 + pageCount := 0 + + for { + pageSize := cfg.pageSize + if cfg.limit != nil { + remaining := *cfg.limit - itemCount + if remaining <= 0 { + return + } + if pageSize == 0 || int64(remaining) < int64(pageSize) { + if remaining > math.MaxInt32 { + pageSize = math.MaxInt32 + } else { + pageSize = int32(remaining) + } + } + } + if pageSize > 0 { + request = request.PageSize(pageSize) + } + + response, err := request.Execute() + if err != nil { + yield(zero, fmt.Errorf("paginate: fetch page %d: %w", pageCount+1, err)) + return + } + pageCount++ + + items := response.GetItems() + if cfg.limit != nil { + remaining := *cfg.limit - itemCount + if len(items) > remaining { + items = items[:remaining] + } + } + for _, item := range items { + itemCount++ + if !yield(item, nil) { + return + } + } + + if cfg.limit != nil && itemCount >= *cfg.limit { + return + } + + nextPageToken := response.GetNextPageToken() + if nextPageToken == "" { + return + } + if cfg.maxPages != nil && pageCount >= *cfg.maxPages { + return + } + if _, exists := seenTokens[nextPageToken]; exists { + yield(zero, fmt.Errorf("paginate: page %d returned an already used next page token", pageCount)) + return + } + seenTokens[nextPageToken] = struct{}{} + request = request.PageToken(nextPageToken) + } + } +} + +// All retrieves and returns all items yielded by Items. If pagination fails, +// All returns the items retrieved before the failure together with the error. +func All[ + Item any, + Resp Response[Item], + Req Request[Req, Resp], +](request Req, opts ...Option) ([]Item, error) { + var items []Item + for item, err := range Items(request, opts...) { + if err != nil { + return items, err + } + items = append(items, item) + } + return items, nil +} + +func applyOptions(opts []Option) (options, error) { + var cfg options + for i, opt := range opts { + if opt == nil { + return options{}, fmt.Errorf("option %d is nil", i+1) + } + if err := opt(&cfg); err != nil { + return options{}, err + } + } + return cfg, nil +} diff --git a/core/paginate/paginate_test.go b/core/paginate/paginate_test.go new file mode 100644 index 000000000..c965bc4fe --- /dev/null +++ b/core/paginate/paginate_test.go @@ -0,0 +1,427 @@ +package paginate_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/stackitcloud/stackit-sdk-go/core/paginate" +) + +type testItem struct { + ID int `json:"id"` +} + +type testResponse struct { + Items []testItem `json:"items"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +func (r *testResponse) GetItems() []testItem { return r.Items } +func (r *testResponse) GetNextPageToken() string { return r.NextPageToken } + +type testRequest struct { + ctx context.Context + client *http.Client + endpoint string + pageSize int32 + pageToken string +} + +func (r testRequest) Context(ctx context.Context) testRequest { + r.ctx = ctx + return r +} + +func (r testRequest) PageSize(pageSize int32) testRequest { + r.pageSize = pageSize + return r +} + +func (r testRequest) PageToken(pageToken string) testRequest { + r.pageToken = pageToken + return r +} + +func (r testRequest) Execute() (*testResponse, error) { + u, err := url.Parse(r.endpoint) + if err != nil { + return nil, err + } + query := u.Query() + if r.pageSize > 0 { + query.Set("pageSize", strconv.FormatInt(int64(r.pageSize), 10)) + } + if r.pageToken != "" { + query.Set("pageToken", r.pageToken) + } + u.RawQuery = query.Encode() + + ctx := r.ctx + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody) + if err != nil { + return nil, err + } + + resp, err := r.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status %s", resp.Status) + } + + var result testResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return &result, nil +} + +func TestAllPaginatesHTTPListOperation(t *testing.T) { + var queries []url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.Query()) + pages := map[string]testResponse{ + "": {Items: []testItem{{1}, {2}, {3}}, NextPageToken: "page-a"}, + "page-a": {Items: []testItem{{4}, {5}, {6}}, NextPageToken: "page-b"}, + "page-b": {Items: []testItem{{7}}}, + } + page, ok := pages[r.URL.Query().Get("pageToken")] + if !ok { + http.Error(w, "unknown page token", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(page) + })) + defer server.Close() + + got, err := paginate.All(newTestRequest(server), paginate.WithPageSize(3)) + if err != nil { + t.Fatalf("All() error = %v", err) + } + want := []testItem{{1}, {2}, {3}, {4}, {5}, {6}, {7}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("All() mismatch (-want +got):\n%s", diff) + } + + wantQueries := []url.Values{ + {"pageSize": {"3"}}, + {"pageSize": {"3"}, "pageToken": {"page-a"}}, + {"pageSize": {"3"}, "pageToken": {"page-b"}}, + } + if diff := cmp.Diff(wantQueries, queries); diff != "" { + t.Errorf("queries mismatch (-want +got):\n%s", diff) + } +} + +func TestAllLimitAdjustsLastPageSize(t *testing.T) { + var queries []url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.Query()) + if r.URL.Query().Get("pageToken") == "" { + _ = json.NewEncoder(w).Encode(testResponse{ + Items: []testItem{{1}, {2}, {3}, {4}}, NextPageToken: "next", + }) + return + } + _ = json.NewEncoder(w).Encode(testResponse{ + Items: []testItem{{5}, {6}}, NextPageToken: "not-requested", + }) + })) + defer server.Close() + + got, err := paginate.All(newTestRequest(server), paginate.WithPageSize(4), paginate.WithLimit(6)) + if err != nil { + t.Fatalf("All() error = %v", err) + } + want := []testItem{{1}, {2}, {3}, {4}, {5}, {6}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("All() mismatch (-want +got):\n%s", diff) + } + wantQueries := []url.Values{ + {"pageSize": {"4"}}, + {"pageSize": {"2"}, "pageToken": {"next"}}, + } + if diff := cmp.Diff(wantQueries, queries); diff != "" { + t.Errorf("queries mismatch (-want +got):\n%s", diff) + } +} + +func TestItemsIsLazyAndStopsWithConsumer(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + _ = json.NewEncoder(w).Encode(testResponse{ + Items: []testItem{{requests*2 - 1}, {requests * 2}}, NextPageToken: fmt.Sprintf("page-%d", requests+1), + }) + })) + defer server.Close() + + sequence := paginate.Items(newTestRequest(server), paginate.WithPageSize(2)) + if requests != 0 { + t.Fatalf("Items made %d requests before iteration", requests) + } + + var got []testItem + for item, err := range sequence { + if err != nil { + t.Fatalf("Items() error = %v", err) + } + got = append(got, item) + if len(got) == 2 { + break + } + } + want := []testItem{{1}, {2}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Items() mismatch (-want +got):\n%s", diff) + } + if requests != 1 { + t.Errorf("request count = %d, want 1", requests) + } +} + +func TestAllContinuesAfterEmptyNonFinalPage(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + if requests == 1 { + _ = json.NewEncoder(w).Encode(testResponse{NextPageToken: "next"}) + return + } + _ = json.NewEncoder(w).Encode(testResponse{Items: []testItem{{1}}}) + })) + defer server.Close() + + got, err := paginate.All(newTestRequest(server)) + if err != nil { + t.Fatalf("All() error = %v", err) + } + want := []testItem{{1}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("All() mismatch (-want +got):\n%s", diff) + } + if requests != 2 { + t.Errorf("request count = %d, want 2", requests) + } +} + +func TestAllReturnsPartialItemsAndHTTPError(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + if requests == 1 { + _ = json.NewEncoder(w).Encode(testResponse{Items: []testItem{{1}}, NextPageToken: "next"}) + return + } + http.Error(w, "failed", http.StatusInternalServerError) + })) + defer server.Close() + + got, err := paginate.All(newTestRequest(server)) + if err == nil { + t.Fatal("All() error = nil, want an error") + } + want := []testItem{{1}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("All() partial result mismatch (-want +got):\n%s", diff) + } + if want := "paginate: fetch page 2"; len(err.Error()) < len(want) || err.Error()[:len(want)] != want { + t.Errorf("All() error = %q, want prefix %q", err, want) + } +} + +func TestAllMaxPages(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + _ = json.NewEncoder(w).Encode(testResponse{ + Items: []testItem{{requests}}, NextPageToken: fmt.Sprintf("next-%d", requests), + }) + })) + defer server.Close() + + got, err := paginate.All(newTestRequest(server), paginate.WithMaxPages(2)) + if err != nil { + t.Fatalf("All() error = %v", err) + } + want := []testItem{{1}, {2}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("All() mismatch (-want +got):\n%s", diff) + } + if requests != 2 { + t.Errorf("request count = %d, want 2", requests) + } +} + +func TestAllRejectsInvalidOptionsWithoutRequest(t *testing.T) { + tests := []struct { + name string + opt paginate.Option + }{ + {name: "negative page size", opt: paginate.WithPageSize(-1)}, + {name: "negative limit", opt: paginate.WithLimit(-1)}, + {name: "negative maximum pages", opt: paginate.WithMaxPages(-1)}, + {name: "nil option", opt: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + requests++ + })) + defer server.Close() + + _, err := paginate.All(newTestRequest(server), tt.opt) + if err == nil { + t.Fatal("All() error = nil, want an error") + } + if requests != 0 { + t.Errorf("request count = %d, want 0", requests) + } + }) + } +} + +func TestAllZeroLimitsDoNotRequest(t *testing.T) { + for _, opt := range []paginate.Option{paginate.WithLimit(0), paginate.WithMaxPages(0)} { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + requests++ + })) + + got, err := paginate.All(newTestRequest(server), opt) + server.Close() + if err != nil { + t.Fatalf("All() error = %v", err) + } + if len(got) != 0 { + t.Errorf("All() = %v, want no items", got) + } + if requests != 0 { + t.Errorf("request count = %d, want 0", requests) + } + } +} + +func TestItemsStopsOnRepeatedPageToken(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + _ = json.NewEncoder(w).Encode(testResponse{NextPageToken: "same-token"}) + })) + defer server.Close() + + _, err := paginate.All(newTestRequest(server)) + if err == nil { + t.Fatal("All() error = nil, want an error") + } + if requests != 2 { + t.Errorf("request count = %d, want 2", requests) + } +} + +func TestAllServerCapsPageSize(t *testing.T) { + var queries []url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.Query()) + pages := map[string]testResponse{ + "": {Items: []testItem{{1}, {2}}, NextPageToken: "page-2"}, + "page-2": {Items: []testItem{{3}, {4}}, NextPageToken: "page-3"}, + "page-3": {Items: []testItem{{5}}}, + } + page, ok := pages[r.URL.Query().Get("pageToken")] + if !ok { + http.Error(w, "unknown page token", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(page) + })) + defer server.Close() + + got, err := paginate.All(newTestRequest(server), paginate.WithPageSize(3)) + if err != nil { + t.Fatalf("All() error = %v", err) + } + want := []testItem{{1}, {2}, {3}, {4}, {5}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("All() mismatch (-want +got):\n%s", diff) + } + + wantQueries := []url.Values{ + {"pageSize": {"3"}}, + {"pageSize": {"3"}, "pageToken": {"page-2"}}, + {"pageSize": {"3"}, "pageToken": {"page-3"}}, + } + if diff := cmp.Diff(wantQueries, queries); diff != "" { + t.Errorf("queries mismatch (-want +got):\n%s", diff) + } +} + +func TestItemsContextCanceledMidIteration(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + pages := map[string]testResponse{ + "": {Items: []testItem{{1}, {2}}, NextPageToken: "page-2"}, + "page-2": {Items: []testItem{{3}, {4}}}, + } + page, ok := pages[r.URL.Query().Get("pageToken")] + if !ok { + http.Error(w, "unknown page token", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(page) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var got []testItem + var iterErr error + for item, err := range paginate.Items(newTestRequest(server).Context(ctx)) { + if err != nil { + iterErr = err + break + } + got = append(got, item) + if len(got) == 2 { + cancel() + } + } + + if iterErr == nil { + t.Fatal("Items() error = nil, want context canceled error") + } + if !errors.Is(iterErr, context.Canceled) { + t.Errorf("Items() error = %v, want context.Canceled", iterErr) + } + want := []testItem{{1}, {2}} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Items() mismatch (-want +got):\n%s", diff) + } + if requests != 1 { + t.Errorf("request count = %d, want 1", requests) + } +} + +func newTestRequest(server *httptest.Server) testRequest { + return testRequest{ctx: context.Background(), client: server.Client(), endpoint: server.URL} +} diff --git a/examples/paginate/go.mod b/examples/paginate/go.mod new file mode 100644 index 000000000..938fddced --- /dev/null +++ b/examples/paginate/go.mod @@ -0,0 +1,21 @@ +module paginate + +go 1.25 + +// This is not needed in production. This is only here to point the golangci linter to the local version instead of the last release on GitHub. +replace github.com/stackitcloud/stackit-sdk-go/services/automation => ../../services/automation + +replace github.com/stackitcloud/stackit-sdk-go/services/albwaf => ../../services/albwaf + +replace github.com/stackitcloud/stackit-sdk-go/core => ../../core + +require ( + github.com/stackitcloud/stackit-sdk-go/core v0.26.0 + github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.2 + github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.0 +) + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/uuid v1.6.0 // indirect +) diff --git a/examples/paginate/go.sum b/examples/paginate/go.sum new file mode 100644 index 000000000..856cf417c --- /dev/null +++ b/examples/paginate/go.sum @@ -0,0 +1,6 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/examples/paginate/paginate.go b/examples/paginate/paginate.go new file mode 100644 index 000000000..60b761b1f --- /dev/null +++ b/examples/paginate/paginate.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strconv" + + "github.com/stackitcloud/stackit-sdk-go/core/paginate" + albwaf "github.com/stackitcloud/stackit-sdk-go/services/albwaf/v1api" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" +) + +const region = "eu01" + +func main() { + ctx := context.Background() + projectId := "" + if projectId == "" { + projectId = os.Getenv("PROJECT_ID") + } + if projectId == "" { + log.Fatal("projectId is required") + } + aipCompliantExample(ctx, projectId) + fmt.Print("\n\n") + err := adapterExample(ctx, projectId) + if err != nil { + log.Fatal(err) + } +} + +func aipCompliantExample(ctx context.Context, projectId string) { + client, err := automation.NewAPIClient() + if err != nil { + log.Fatal("create automation client: ", err) + } + req := client.DefaultAPI.ListVolumeTemplates(ctx, projectId, region) + items, err := paginate.All( + req, + paginate.WithLimit(2), + ) + if err != nil { + log.Fatal("list items: ", err) + } + fmt.Println("listing automation templates") + fmt.Printf("%+v\n", items) +} + +func adapterExample(ctx context.Context, projectId string) error { + // setup + client, err := albwaf.NewAPIClient() + if err != nil { + return fmt.Errorf("create albwaf client: %w", err) + } + teardown, err := setup(ctx, client, projectId) + defer func() { + err := teardown() + if err != nil { + log.Fatal("teardown: ", err) + } + }() + if err != nil { + return fmt.Errorf("setup: %w", err) + } + + // list items + fmt.Println("listing albwaf managed rule sets") + req := client.DefaultAPI.ListManagedRuleSets(ctx, projectId, region) + for item, err := range paginate.Items( + &listManagedRuleSetsAdapter{request: req}, + paginate.WithLimit(5), + paginate.WithPageSize(2), + ) { + if err != nil { + return fmt.Errorf("list items: %w", err) + } + fmt.Printf("%+v\n", item) + } + return nil +} + +func setup(ctx context.Context, client *albwaf.APIClient, projectId string) (teardown func() error, err error) { + req := client.DefaultAPI.CreateManagedRuleSet(ctx, projectId, region) + var created []string + teardown = func() error { + for _, name := range created { + _, teardownErr := client.DefaultAPI.DeleteManagedRuleSet(ctx, projectId, region, name).Execute() + if teardownErr != nil { + return teardownErr + } + } + return nil + } + for n := range 10 { + payload := albwaf.CreateManagedRuleSetPayload{ + Name: fmt.Sprintf("%s-%d", projectId, n), + Type: albwaf.TYPE_TYPE_OWASP_CRS, + } + ruleSet, err := req.CreateManagedRuleSetPayload(payload).Execute() + if err != nil { + return teardown, err + } + created = append(created, ruleSet.Name) + } + return teardown, nil +} + +// adapter +type listManagedRuleSetsAdapter struct { + request albwaf.ApiListManagedRuleSetsRequest +} + +func (a *listManagedRuleSetsAdapter) PageSize(pageSize int32) *listManagedRuleSetsAdapter { + a.request = a.request.PageSize(strconv.Itoa(int(pageSize))) + return a +} + +func (a *listManagedRuleSetsAdapter) PageToken(pageToken string) *listManagedRuleSetsAdapter { + a.request = a.request.PageId(pageToken) + return a +} + +func (a *listManagedRuleSetsAdapter) Execute() (listManagedRuleSetsResponse, error) { + resp, err := a.request.Execute() + if err != nil { + return listManagedRuleSetsResponse{}, err + } + return listManagedRuleSetsResponse{ListManagedRuleSetResponse: resp}, nil +} + +type listManagedRuleSetsResponse struct { + *albwaf.ListManagedRuleSetResponse +} + +func (r listManagedRuleSetsResponse) GetNextPageToken() string { + return r.GetNextPageId() +} diff --git a/go.work b/go.work index d99cd259b..5ae42d1c7 100644 --- a/go.work +++ b/go.work @@ -26,6 +26,7 @@ use ( ./examples/objectstorage ./examples/observability ./examples/opensearch + ./examples/paginate ./examples/postgresflex ./examples/rabbitmq ./examples/redis