diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3066f1b..f335dc9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.24 + go-version: 1.25 - name: Build run: go build -v ./... diff --git a/Dockerfile b/Dockerfile index 59c6948..26dacd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24-alpine AS builder +FROM golang:1.25-alpine AS builder LABEL maintainer="github:@rusq" WORKDIR /build diff --git a/addr.go b/addr.go index 5ba0a5d..a51b69d 100644 --- a/addr.go +++ b/addr.go @@ -1,9 +1,10 @@ package aklapi import ( + "context" "encoding/json" "errors" - "log" + "log/slog" "net/http" "strconv" "time" @@ -36,19 +37,19 @@ func (s Address) String() string { } // AddressLookup is a convenience function to get addresses. -func AddressLookup(addr string) (*AddrResponse, error) { - return MatchingPropertyAddresses(&AddrRequest{SearchText: addr, PageSize: 10}) +func AddressLookup(ctx context.Context, addr string) (*AddrResponse, error) { + return MatchingPropertyAddresses(ctx, &AddrRequest{SearchText: addr, PageSize: 10}) } // MatchingPropertyAddresses wrapper around the AKL Council API. -func MatchingPropertyAddresses(addrReq *AddrRequest) (*AddrResponse, error) { +func MatchingPropertyAddresses(ctx context.Context, addrReq *AddrRequest) (*AddrResponse, error) { cachedAr, ok := addrCache.Lookup(addrReq.SearchText) if ok { - log.Printf("cached address result: %q", cachedAr) + slog.DebugContext(ctx, "found cached address result", "addr", cachedAr) return cachedAr, nil } - req, err := http.NewRequest("GET", addrURI, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, addrURI, nil) if err != nil { return nil, err } @@ -66,7 +67,7 @@ func MatchingPropertyAddresses(addrReq *AddrRequest) (*AddrResponse, error) { return nil, err } defer resp.Body.Close() - log.Printf("address call complete in %s", time.Since(start)) + slog.DebugContext(ctx, "address call complete", "duration", time.Since(start)) if resp.StatusCode != http.StatusOK { return nil, errors.New("address API returned status code: " + strconv.Itoa(resp.StatusCode)) @@ -82,8 +83,8 @@ func MatchingPropertyAddresses(addrReq *AddrRequest) (*AddrResponse, error) { return &apiResp, nil } -func oneAddress(addr string) (*Address, error) { - resp, err := AddressLookup(addr) +func oneAddress(ctx context.Context, addr string) (*Address, error) { + resp, err := AddressLookup(ctx, addr) if err != nil { return nil, err } diff --git a/addr_cache.go b/addr_cache.go deleted file mode 100644 index 240ef09..0000000 --- a/addr_cache.go +++ /dev/null @@ -1,17 +0,0 @@ -package aklapi - -type addrResponseCache map[string]*AddrResponse - -var addrCache = make(addrResponseCache) - -func (c addrResponseCache) Lookup(searchText string) (resp *AddrResponse, ok bool) { - if NoCache { - return nil, false - } - resp, ok = c[searchText] - return -} - -func (c addrResponseCache) Add(searchText string, ar *AddrResponse) { - c[searchText] = ar -} diff --git a/addr_cache_test.go b/addr_cache_test.go deleted file mode 100644 index 3b25fe4..0000000 --- a/addr_cache_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package aklapi - -import ( - "reflect" - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test_addrResponseCache_Lookup(t *testing.T) { - defer func() { - NoCache = false - }() - type args struct { - searchText string - } - tests := []struct { - name string - NoCache bool // if true, set NoCache to true before running test - c addrResponseCache - args args - wantResp *AddrResponse - wantOk bool - }{ - {"not in cache", - false, - addrResponseCache{ - "xxx": &AddrResponse{Items: []Address{*testAddr}}, - }, - args{"yyy"}, - nil, - false, - }, - {"cached", - false, - addrResponseCache{ - testAddr.Address: &AddrResponse{Items: []Address{*testAddr}}, - }, - args{testAddr.Address}, - &AddrResponse{Items: []Address{*testAddr}}, - true, - }, - {"cached, no cache mode", - true, - addrResponseCache{ - testAddr.Address: &AddrResponse{Items: []Address{*testAddr}}, - }, - args{testAddr.Address}, - nil, - false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - NoCache = tt.NoCache - gotResp, gotOk := tt.c.Lookup(tt.args.searchText) - if !reflect.DeepEqual(gotResp, tt.wantResp) { - t.Errorf("addrResponseCache.Lookup() gotResp = %v, want %v", gotResp, tt.wantResp) - } - if gotOk != tt.wantOk { - t.Errorf("addrResponseCache.Lookup() gotOk = %v, want %v", gotOk, tt.wantOk) - } - }) - } -} - -func Test_addrResponseCache_Add(t *testing.T) { - type args struct { - searchText string - ar *AddrResponse - } - tests := []struct { - name string - c addrResponseCache - args args - wantAddrResponseCache addrResponseCache - }{ - {"add", - addrResponseCache{}, - args{testAddr.Address, &AddrResponse{Items: []Address{*testAddr}}}, - addrResponseCache{ - testAddr.Address: &AddrResponse{Items: []Address{*testAddr}}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.c.Add(tt.args.searchText, tt.args.ar) - assert.Equal(t, tt.wantAddrResponseCache, tt.c) - }) - } -} diff --git a/addr_test.go b/addr_test.go index ca345aa..3999713 100644 --- a/addr_test.go +++ b/addr_test.go @@ -68,7 +68,7 @@ func TestMatchingPropertyAddresses(t *testing.T) { oldURI := addrURI defer func() { addrURI = oldURI }() addrURI = tt.testSrv.URL - got, err := MatchingPropertyAddresses(tt.args.addrReq) + got, err := MatchingPropertyAddresses(t.Context(), tt.args.addrReq) if (err != nil) != tt.wantErr { t.Errorf("MatchingPropertyAddresses() error = %v, wantErr %v", err, tt.wantErr) return @@ -106,7 +106,7 @@ func TestAddress(t *testing.T) { oldURI := addrURI defer func() { addrURI = oldURI }() addrURI = tt.testSrv.URL - got, err := AddressLookup(tt.args.addr) + got, err := AddressLookup(t.Context(), tt.args.addr) if (err != nil) != tt.wantErr { t.Errorf("Address() error = %v, wantErr %v", err, tt.wantErr) return @@ -160,7 +160,7 @@ func Test_oneAddress(t *testing.T) { oldURI := addrURI defer func() { addrURI = oldURI }() addrURI = tt.testSrv.URL - got, err := oneAddress(tt.args.addr) + got, err := oneAddress(t.Context(), tt.args.addr) if (err != nil) != tt.wantErr { t.Errorf("oneAddress() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/aklapi.go b/aklapi.go index c770167..21988ea 100644 --- a/aklapi.go +++ b/aklapi.go @@ -1,11 +1,9 @@ package aklapi import ( - "regexp" "time" ) var ( defaultLoc, _ = time.LoadLocation("Pacific/Auckland") // Auckland is in NZ. - dow = regexp.MustCompile("Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday") ) diff --git a/caches.go b/caches.go new file mode 100644 index 0000000..d603c38 --- /dev/null +++ b/caches.go @@ -0,0 +1,62 @@ +package aklapi + +import ( + "github.com/phuslu/lru" +) + +const defCacheSz = 100 // seems reasonable. +var ( + addrCache = newLRUCache[string, *AddrResponse](defCacheSz) + rubbishCache = rubbishResultCache{lc: newLRUCache[string, *CollectionDayDetailResult](defCacheSz)} +) + +type lruCache[K comparable, V any] struct { + cache *lru.LRUCache[K, V] +} + +func newLRUCache[K comparable, V any](size int) *lruCache[K, V] { + return &lruCache[K, V]{ + cache: lru.NewLRUCache[K, V](size), + } +} + +func (c *lruCache[K, V]) Lookup(key K) (resp V, ok bool) { + var nothing V + if NoCache { + return nothing, false + } + return c.cache.Get(key) +} + +func (c *lruCache[K, V]) Add(key K, value V) { + c.cache.Set(key, value) +} + +func (c *lruCache[K, V]) Delete(key K) { + c.cache.Delete(key) +} + +type rubbishResultCache struct { + lc *lruCache[string, *CollectionDayDetailResult] +} + +func (c *rubbishResultCache) Lookup(searchText string) (result *CollectionDayDetailResult, ok bool) { + result, ok = c.lc.Lookup(searchText) + if !ok { + return nil, false + } + + today := now() + for _, res := range result.Collections { + if today.After(res.Date) || res.Date.IsZero() { + // invalidate from cache. + c.lc.Delete(searchText) + return nil, false + } + } + return +} + +func (c *rubbishResultCache) Add(searchText string, result *CollectionDayDetailResult) { + c.lc.Add(searchText, result) +} diff --git a/caches_test.go b/caches_test.go new file mode 100644 index 0000000..30b4d65 --- /dev/null +++ b/caches_test.go @@ -0,0 +1,246 @@ +package aklapi + +import ( + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func newLRUFromMap[K comparable, V any](m map[K]V) *lruCache[K, V] { + ret := newLRUCache[K, V](defCacheSz) + for k, v := range m { + ret.Add(k, v) + } + return ret +} + +func Test_addrResponseCache_Lookup(t *testing.T) { + defer func() { + NoCache = false + }() + type args struct { + searchText string + } + tests := []struct { + name string + NoCache bool // if true, set NoCache to true before running test + c *lruCache[string, *AddrResponse] + args args + wantResp *AddrResponse + wantOk bool + }{ + {"not in cache", + false, + newLRUFromMap(map[string]*AddrResponse{ + "xxx": &AddrResponse{Items: []Address{*testAddr}}, + }), + args{"yyy"}, + nil, + false, + }, + {"cached", + false, + newLRUFromMap(map[string]*AddrResponse{ + testAddr.Address: &AddrResponse{Items: []Address{*testAddr}}, + }), + args{testAddr.Address}, + &AddrResponse{Items: []Address{*testAddr}}, + true, + }, + {"cached, no cache mode", + true, + newLRUFromMap(map[string]*AddrResponse{ + testAddr.Address: &AddrResponse{Items: []Address{*testAddr}}, + }), + args{testAddr.Address}, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + NoCache = tt.NoCache + gotResp, gotOk := tt.c.Lookup(tt.args.searchText) + if !reflect.DeepEqual(gotResp, tt.wantResp) { + t.Errorf("addrResponseCache.Lookup() gotResp = %v, want %v", gotResp, tt.wantResp) + } + if gotOk != tt.wantOk { + t.Errorf("addrResponseCache.Lookup() gotOk = %v, want %v", gotOk, tt.wantOk) + } + }) + } +} + +func Test_addrResponseCache_Add(t *testing.T) { + type args struct { + searchText string + ar *AddrResponse + } + tests := []struct { + name string + c *lruCache[string, *AddrResponse] + args args + want *AddrResponse + }{ + {"add", + newLRUCache[string, *AddrResponse](10), + args{testAddr.Address, &AddrResponse{Items: []Address{*testAddr}}}, + &AddrResponse{Items: []Address{*testAddr}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.c.Add(tt.args.searchText, tt.args.ar) + + got, ok := tt.c.Lookup(tt.args.searchText) + assert.True(t, ok) + assert.Equal(t, got, tt.want) + }) + } +} + +var ( + tomorrow = &CollectionDayDetailResult{ + Collections: []RubbishCollection{ + {Date: time.Now().Add(24 * time.Hour), Rubbish: true}, + {Date: time.Now().Add(7 * 24 * time.Hour), Rubbish: true}, + }, + Address: testAddr, + } + today = &CollectionDayDetailResult{ + Collections: []RubbishCollection{ + {Date: time.Now().Add(1 * time.Minute), Rubbish: true}, + {Date: time.Now().Add(7 * 24 * time.Hour), Rubbish: true}, + }, + Address: testAddr, + } + yesterday = &CollectionDayDetailResult{ + Collections: []RubbishCollection{ + {Date: time.Now().Add(-24 * time.Hour), Rubbish: true}, + {Date: time.Now().Add(5 * 24 * time.Hour), Rubbish: true}, + }, + Address: testAddr, + } + secondYesterday = &CollectionDayDetailResult{ + Collections: []RubbishCollection{ + {Date: time.Now().Add(24 * time.Hour), Rubbish: true}, + {Date: time.Now().Add(-24 * time.Hour), Rubbish: true}, + }, + Address: testAddr, + } +) + +func newRubbishCacheFromMap(m map[string]*CollectionDayDetailResult) *rubbishResultCache { + var res = rubbishResultCache{ + lc: newLRUCache[string, *CollectionDayDetailResult](defCacheSz), + } + for k, v := range m { + res.Add(k, v) + } + return &res +} + +func Test_rubbishResultCache_Lookup(t *testing.T) { + defer func() { + NoCache = false + }() + type args struct { + searchText string + } + tests := []struct { + name string + NoCache bool // if true, set NoCache to true before running test + c *rubbishResultCache + args args + wantResult *CollectionDayDetailResult + wantOk bool + }{ + {"not in cache (empty)", false, newRubbishCacheFromMap(map[string]*CollectionDayDetailResult{}), args{"blah"}, nil, false}, + {"not in cache", + false, + newRubbishCacheFromMap(map[string]*CollectionDayDetailResult{ + testAddr.Address: tomorrow, + }), + args{"blah"}, + nil, false}, + {"in cache (future)", + false, + newRubbishCacheFromMap(map[string]*CollectionDayDetailResult{ + testAddr.Address: tomorrow, + }), + args{testAddr.Address}, + tomorrow, true}, + {"in cache (today)", + false, + newRubbishCacheFromMap(map[string]*CollectionDayDetailResult{ + testAddr.Address: today, + }), + args{testAddr.Address}, + today, true}, + {"in cache, expired", + false, + newRubbishCacheFromMap(map[string]*CollectionDayDetailResult{ + testAddr.Address: yesterday, + }), + args{testAddr.Address}, + nil, false}, + {"in cache, second entry expired", + false, + newRubbishCacheFromMap(map[string]*CollectionDayDetailResult{ + testAddr.Address: secondYesterday, + }), + args{testAddr.Address}, + nil, false}, + {"no cache", + true, + newRubbishCacheFromMap(map[string]*CollectionDayDetailResult{ + testAddr.Address: secondYesterday, + }), + args{testAddr.Address}, + nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + NoCache = tt.NoCache + gotResult, gotOk := tt.c.Lookup(tt.args.searchText) + if !reflect.DeepEqual(gotResult, tt.wantResult) { + t.Errorf("rubbishResultCache.Lookup() gotResult = %v, want %v", gotResult, tt.wantResult) + } + if gotOk != tt.wantOk { + t.Errorf("rubbishResultCache.Lookup() gotOk = %v, want %v", gotOk, tt.wantOk) + } + }) + } +} + +func Test_rubbishResultCache_Add(t *testing.T) { + type args struct { + searchText string + ar *CollectionDayDetailResult + } + tests := []struct { + name string + c *rubbishResultCache + args args + want *CollectionDayDetailResult + }{ + {"add", + newRubbishCacheFromMap(map[string]*CollectionDayDetailResult{ + testAddr.Address: tomorrow, + }), + args{testAddr.Address, tomorrow}, + tomorrow, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.c.Add(tt.args.searchText, tt.args.ar) + + res, ok := tt.c.Lookup(tt.args.searchText) + assert.True(t, ok) + assert.Equal(t, tt.want, res) + }) + } +} diff --git a/cmd/aklapi/assets.go b/cmd/aklapi/assets.go index 9004621..5355356 100644 --- a/cmd/aklapi/assets.go +++ b/cmd/aklapi/assets.go @@ -17,7 +17,7 @@ const rootHTML = `
AT SOME POINT IN TIME, FUTURE WILL BE DIFFERENT FROM THE PRESENT.
+AT SOME POINT, THE FUTURE WILL BE DIFFERENT FROM THE PRESENT.