Skip to content

Commit 69f1f7e

Browse files
committed
implement iteragor for resources that support pagination
1 parent bc2a6ec commit 69f1f7e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+2909
-594
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## 0.104.0
6+
7+
ENHANCEMENTS:
8+
9+
* **Iterator Support**: Added comprehensive iterator functionality for handling both paginated and non-paginated API responses
10+
* New `Iterator` interface with idiomatic Go methods: `Next() (RecordSet, error)`, `Previous() (RecordSet, error)`, `HasNext()`, `HasPrevious()`, `Reset() (RecordSet, error)`, `All() (RecordSet, error)`, `Count()`, `PageSize()`
11+
* Added `GetIterator(params, pageSize)` and `GetIteratorWithContext(ctx, params, pageSize)` methods to all resources
12+
* Automatic detection of paginated vs non-paginated responses
13+
* Support for bidirectional navigation (forward and backward)
14+
* Lazy loading of pages for memory efficiency
15+
* Comprehensive documentation and examples in `docs/iterators.md`
16+
* Pagination envelope unwrapping removed from interceptors - Iterator handles it directly
17+
* `List()` now uses `GetIterator().All()` internally - Iterator is the base abstraction
18+
* Global `PageSize` configuration in `VMSConfig` (default: 0 means no page_size param, let server decide)
19+
* Can override page size per iterator, or use 0 to omit page_size parameter
20+
521
## 0.103.0
622

723
ENHANCEMENTS:

client.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ type (
2828
// Renderable is the interface for Record, RecordSet, and EmptyRecord.
2929
Renderable = core.Renderable
3030

31+
// DisplayableRecord is an interface for records that can be displayed.
32+
DisplayableRecord = core.DisplayableRecord
33+
34+
// ApiError represents an error from the VAST API.
35+
ApiError = core.ApiError
36+
3137
// TypedVMSRest is the strongly-typed client with compile-time type safety.
3238
TypedVMSRest = rest.TypedVMSRest
3339

@@ -44,6 +50,24 @@ type (
4450
InterceptableVastResourceAPI = core.InterceptableVastResourceAPI
4551
)
4652

53+
// Error handling functions
54+
var (
55+
// IsNotFoundErr checks if the error is a 404 Not Found error.
56+
IsNotFoundErr = core.IsNotFoundErr
57+
58+
// IgnoreNotFound returns nil if the error is a 404, otherwise returns the original error.
59+
IgnoreNotFound = core.IgnoreNotFound
60+
61+
// IgnoreStatusCodes returns nil if the error is an API error with one of the specified status codes.
62+
IgnoreStatusCodes = core.IgnoreStatusCodes
63+
64+
// ExpectStatusCodes checks if the error is an API error with one of the specified status codes.
65+
ExpectStatusCodes = core.ExpectStatusCodes
66+
67+
// IsApiError checks if the error is an ApiError.
68+
IsApiError = core.IsApiError
69+
)
70+
4771
// NewTypedVMSRest creates a strongly-typed client with compile-time type safety.
4872
// Use when you need strict API contracts and IDE auto-completion.
4973
func NewTypedVMSRest(config *VMSConfig) (*TypedVMSRest, error) {

core/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type VMSConfig struct {
2424
MaxConnections int // Maximum number of concurrent HTTP connections.
2525
UserAgent string // Optional custom User-Agent header to use in HTTP requests. If empty, a default may be applied.
2626
ApiVersion string // Optional API version
27+
PageSize int // Default page size for iterators
2728
// Context is an optional external context for controlling HTTP request lifecycle.
2829
// When provided, it will be used as the parent context for all HTTP requests made by the client.
2930
Context context.Context

core/interceptors.go

Lines changed: 2 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func (e *VastResource) doAfterRequest(ctx context.Context, response Renderable)
7575
if !isDummyResource {
7676
// Pre-normalization: attach @resourceType so resource hooks and user AfterRequestFn
7777
// can rely on it for formatting/logging/branching even if later mutations change shape.
78-
if err = SetResourceKey(response, resourceType); err != nil {
78+
if err = setResourceKey(response, resourceType); err != nil {
7979
return nil, err
8080
}
8181
}
@@ -103,7 +103,7 @@ func (e *VastResource) doAfterRequest(ctx context.Context, response Renderable)
103103
// Post-normalization: re-attach @resourceType, because mutations can produce new
104104
// Record/RecordSet instances which won't carry the earlier key.
105105
if !isDummyResource {
106-
if err = SetResourceKey(mutated, resourceType); err != nil {
106+
if err = setResourceKey(mutated, resourceType); err != nil {
107107
return nil, err
108108
}
109109
}
@@ -126,42 +126,8 @@ func defaultResponseMutations(response Renderable) (Renderable, error) {
126126
}
127127
return nil, fmt.Errorf("expected map[string]any under 'async_task', got %T", raw)
128128
}
129-
// Normalize pagination envelope when response is a single Record
130-
if rs, matched, err := unwrapPaginationEnvelopeFromRecord(typed); matched {
131-
if err != nil {
132-
return nil, err
133-
}
134-
if rs != nil {
135-
return rs, nil
136-
}
137-
}
138129
return response, nil
139130
case RecordSet:
140-
// Normalize list responses that wrap data under a pagination envelope
141-
// Expected keys: "results", "count", "next", "previous"
142-
// Example payload returned as a single Record inside RecordSet: {"results": [...], "count": N, "next": ..., "previous": ...}
143-
if len(typed) == 1 {
144-
// Accept both Record and raw map[string]any as the envelope
145-
if rec, ok := any(typed[0]).(Record); ok {
146-
if rs, matched, err := unwrapPaginationEnvelopeFromRecord(rec); matched {
147-
if err != nil {
148-
return nil, err
149-
}
150-
if rs != nil {
151-
return rs, nil
152-
}
153-
}
154-
} else if raw, ok := any(typed[0]).(map[string]any); ok {
155-
if rs, matched, err := unwrapPaginationEnvelopeFromRecord(Record(raw)); matched {
156-
if err != nil {
157-
return nil, err
158-
}
159-
if rs != nil {
160-
return rs, nil
161-
}
162-
}
163-
}
164-
}
165131
return typed, nil
166132
case EmptyRecord:
167133
// No op.
@@ -170,49 +136,6 @@ func defaultResponseMutations(response Renderable) (Renderable, error) {
170136
return nil, fmt.Errorf("unsupported type %T for result", response)
171137
}
172138

173-
// unwrapPaginationEnvelopeFromRecord attempts to detect and unwrap a standard pagination envelope
174-
// of the form {"results": [...], "count": N, "next": ..., "previous": ...} into a RecordSet.
175-
//
176-
// Returns:
177-
// - (RecordSet, true, nil) when envelope matched and conversion succeeded
178-
// - (nil, true, nil) when envelope matched but results are of unsupported type
179-
// - (nil, false, nil) when envelope did not match
180-
// - (nil, true, err) when envelope matched but conversion failed
181-
func unwrapPaginationEnvelopeFromRecord(rec Record) (RecordSet, bool, error) {
182-
_, hasResults := rec["results"]
183-
_, hasCount := rec["count"]
184-
_, hasNext := rec["next"]
185-
_, hasPrev := rec["previous"]
186-
if !(hasResults && hasCount && hasNext && hasPrev) {
187-
return nil, false, nil
188-
}
189-
inner := rec["results"]
190-
// Prefer []map[string]any, but also handle []any of maps
191-
if list, ok := inner.([]map[string]any); ok {
192-
recordSet, err := ToRecordSet(list)
193-
if err != nil {
194-
return nil, true, err
195-
}
196-
return recordSet, true, nil
197-
}
198-
if anyList, ok := inner.([]any); ok {
199-
converted := make([]map[string]any, 0, len(anyList))
200-
for _, it := range anyList {
201-
if m, ok := it.(map[string]any); ok {
202-
converted = append(converted, m)
203-
} else {
204-
return nil, true, nil
205-
}
206-
}
207-
recordSet, err := ToRecordSet(converted)
208-
if err != nil {
209-
return nil, true, err
210-
}
211-
return recordSet, true, nil
212-
}
213-
return nil, true, nil
214-
}
215-
216139
// ######################################################
217140
//
218141
// REQUEST/RESPONSE LOGGING

core/interceptors_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package core
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// Test that defaultResponseMutations no longer unwraps pagination envelopes
8+
func TestDefaultResponseMutations_PaginationNotUnwrapped(t *testing.T) {
9+
// Create a pagination envelope
10+
paginationEnvelope := Record{
11+
"results": []any{
12+
map[string]any{"id": float64(1), "name": "item1"},
13+
map[string]any{"id": float64(2), "name": "item2"},
14+
},
15+
"count": float64(2),
16+
"next": "https://example.com/api/v1/resources/?page=2",
17+
"previous": nil,
18+
}
19+
20+
// Apply defaultResponseMutations
21+
result, err := defaultResponseMutations(paginationEnvelope)
22+
if err != nil {
23+
t.Fatalf("Expected no error, got: %v", err)
24+
}
25+
26+
// Verify that the pagination envelope is NOT unwrapped
27+
resultRecord, ok := result.(Record)
28+
if !ok {
29+
t.Fatalf("Expected Record, got %T", result)
30+
}
31+
32+
// Check that the envelope structure is preserved
33+
if _, hasResults := resultRecord["results"]; !hasResults {
34+
t.Error("Expected results field to be preserved")
35+
}
36+
37+
if _, hasCount := resultRecord["count"]; !hasCount {
38+
t.Error("Expected count field to be preserved")
39+
}
40+
41+
if _, hasNext := resultRecord["next"]; !hasNext {
42+
t.Error("Expected next field to be preserved")
43+
}
44+
45+
if _, hasPrevious := resultRecord["previous"]; !hasPrevious {
46+
t.Error("Expected previous field to be preserved")
47+
}
48+
}
49+
50+
// Test that RecordSet is not altered by defaultResponseMutations
51+
func TestDefaultResponseMutations_RecordSetUnchanged(t *testing.T) {
52+
recordSet := RecordSet{
53+
{"id": float64(1), "name": "item1"},
54+
{"id": float64(2), "name": "item2"},
55+
}
56+
57+
// Apply defaultResponseMutations
58+
result, err := defaultResponseMutations(recordSet)
59+
if err != nil {
60+
t.Fatalf("Expected no error, got: %v", err)
61+
}
62+
63+
// Verify that RecordSet is unchanged
64+
resultRecordSet, ok := result.(RecordSet)
65+
if !ok {
66+
t.Fatalf("Expected RecordSet, got %T", result)
67+
}
68+
69+
if len(resultRecordSet) != 2 {
70+
t.Errorf("Expected 2 records, got %d", len(resultRecordSet))
71+
}
72+
}
73+
74+
// Test that async_task response mutation still works
75+
func TestDefaultResponseMutations_AsyncTask(t *testing.T) {
76+
asyncTaskResponse := Record{
77+
"async_task": map[string]any{
78+
"id": float64(12345),
79+
"state": "RUNNING",
80+
},
81+
}
82+
83+
// Apply defaultResponseMutations
84+
result, err := defaultResponseMutations(asyncTaskResponse)
85+
if err != nil {
86+
t.Fatalf("Expected no error, got: %v", err)
87+
}
88+
89+
// Verify that async_task is normalized
90+
resultRecord, ok := result.(Record)
91+
if !ok {
92+
t.Fatalf("Expected Record, got %T", result)
93+
}
94+
95+
// Check that ResourceTypeKey was added
96+
if resourceType, ok := resultRecord[ResourceTypeKey]; !ok || resourceType != "VTask" {
97+
t.Errorf("Expected ResourceTypeKey to be 'VTask', got: %v", resourceType)
98+
}
99+
}

core/interfaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type VastResourceAPI interface {
2323
GetById(any) (Record, error)
2424
Exists(Params) (bool, error)
2525
MustExists(Params) bool
26+
GetIterator(Params, int) Iterator
2627
// Resource-level mutex lock for concurrent access control
2728
Lock(...any) func()
2829
// Internal methods
@@ -40,6 +41,7 @@ type VastResourceAPIWithContext interface {
4041
GetByIdWithContext(context.Context, any) (Record, error)
4142
ExistsWithContext(context.Context, Params) (bool, error)
4243
MustExistsWithContext(context.Context, Params) bool
44+
GetIteratorWithContext(context.Context, Params, int) Iterator
4345
}
4446

4547
// InterceptableVastResourceAPI combines request interception with vast resource behavior.

0 commit comments

Comments
 (0)