Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cmd/api_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"log"
"os"
"postcode-polygons/internal"
"postcode-polygons/routes"
spatialindex "postcode-polygons/spatial-index"
"time"
Expand Down Expand Up @@ -61,9 +62,10 @@ func ApiServer(zipFile string, port int, debug bool) {
}

cache := memoize.NewMemoizer(5*time.Minute, 10*time.Minute)

repo := internal.NewPolygonsRepo(cache)

r.GET("/v1/postcode/codepoints", routes.CodePointSearch(idx))
r.GET("/v1/postcode/polygons", routes.PolygonSearch(idx, cache))
r.GET("/v1/postcode/polygons", routes.PolygonSearch(idx, repo))

addr := fmt.Sprintf(":%d", port)
log.Printf("Starting HTTP API Server on port %d...", port)
Expand Down
28 changes: 28 additions & 0 deletions internal/polygons_repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package internal

import (
"fmt"

"github.com/kofalt/go-memoize"
"github.com/paulmach/orb/geojson"
)

type PolygonsRepo interface {
RetrieveFeatureCollection(target string, district string) (*geojson.FeatureCollection, error)
}

type CachedPolygonsRepo struct {
cache *memoize.Memoizer
}

func NewPolygonsRepo(cache *memoize.Memoizer) PolygonsRepo {
return &CachedPolygonsRepo{cache: cache}
}

func (cp *CachedPolygonsRepo) RetrieveFeatureCollection(target string, district string) (*geojson.FeatureCollection, error) {
filename := fmt.Sprintf("./data/postcodes/%s/%s.geojson.bz2", target, district)
Comment thread
rm-hull marked this conversation as resolved.
featureCollection, err, _ := memoize.Call(cp.cache, filename, func() (*geojson.FeatureCollection, error) {
return DecompressFeatureCollection(filename)
})
return featureCollection, err
}
13 changes: 5 additions & 8 deletions routes/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"os"

"github.com/gin-gonic/gin"
"github.com/kofalt/go-memoize"
"github.com/paulmach/orb/geojson"
)

Expand Down Expand Up @@ -53,7 +52,7 @@ func CodePointSearch(idx spatialindex.SpatialIndex) func(c *gin.Context) {
}
}

func PolygonSearch(idx spatialindex.SpatialIndex, cache *memoize.Memoizer) func(c *gin.Context) {
func PolygonSearch(idx spatialindex.SpatialIndex, repo internal.PolygonsRepo) func(c *gin.Context) {
return func(c *gin.Context) {
bbox, err := parseBBox(c.Query("bbox"))
if err != nil {
Expand Down Expand Up @@ -91,16 +90,14 @@ func PolygonSearch(idx spatialindex.SpatialIndex, cache *memoize.Memoizer) func(
fc.Features = make([]*geojson.Feature, 0, len(requested))

for district := range districts {
filename := fmt.Sprintf("./data/postcodes/%s/%s.geojson.bz2", target, district)
featureCollection, err, _ := memoize.Call(cache, filename, func() (*geojson.FeatureCollection, error) {
return internal.DecompressFeatureCollection(filename)
})

featureCollection, err := repo.RetrieveFeatureCollection(target, district)
if err != nil && os.IsNotExist(err) {
log.Printf("polygon file %s does not exist, skipping", filename)
log.Printf("polygon file for district %s does not exist, skipping", district)
continue
}
if err != nil {
log.Printf("error loading feature collection from file %s: %v", filename, err)
log.Printf("error loading feature collection for district %s: %v", district, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "An internal server error occurred"})
return
}
Expand Down
109 changes: 103 additions & 6 deletions routes/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import (
"errors"
"net/http"
"net/http/httptest"
"os"
spatialindex "postcode-polygons/spatial-index"
"testing"

"github.com/gin-gonic/gin"
"github.com/kofalt/go-memoize"
"github.com/paulmach/orb/geojson"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -37,6 +38,17 @@ func (m *mockSpatialIndex) Len() int {
return 0
}

type mockPolygonsRepo struct {
RetrieveFeatureCollectionFunc func(target string, district string) (*geojson.FeatureCollection, error)
}

func (m *mockPolygonsRepo) RetrieveFeatureCollection(target string, district string) (*geojson.FeatureCollection, error) {
if m.RetrieveFeatureCollectionFunc != nil {
return m.RetrieveFeatureCollectionFunc(target, district)
}
return nil, nil
}

func TestCodePointSearch_BadBBox(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
Expand Down Expand Up @@ -113,9 +125,7 @@ func TestPolygonSearch_BadBBox(t *testing.T) {
c.Request = httptest.NewRequest("GET", "/polygon?bbox=bad,bbox,values", nil)
c.Request.URL.RawQuery = "bbox=bad,bbox,values"

spatialIdx := &mockSpatialIndex{}
cache := memoize.NewMemoizer(0, 0)
handler := PolygonSearch(spatialIdx, cache)
handler := PolygonSearch(&mockSpatialIndex{}, &mockPolygonsRepo{})
handler(c)

require.Equal(t, http.StatusBadRequest, w.Code)
Expand All @@ -134,8 +144,95 @@ func TestPolygonSearch_InternalError(t *testing.T) {
return errors.New("fail")
},
}
cache := memoize.NewMemoizer(0, 0)
handler := PolygonSearch(spatialIdx, cache)
handler := PolygonSearch(spatialIdx, &mockPolygonsRepo{})
handler(c)

require.Equal(t, http.StatusInternalServerError, w.Code)
require.Contains(t, w.Body.String(), "An internal server error occurred")
}

func TestPolygonSearch_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/polygon?bbox=0,0,1,1", nil)
c.Request.URL.RawQuery = "bbox=0,0,1,1"

spatialIdx := &mockSpatialIndex{
SearchIterFunc: func(bounds []uint32, iter func([2]uint32, [2]uint32, string) bool) error {
iter([2]uint32{0, 0}, [2]uint32{1, 1}, "AB1 2CD")
return nil
},
}

repo := &mockPolygonsRepo{
RetrieveFeatureCollectionFunc: func(target string, district string) (*geojson.FeatureCollection, error) {
require.Equal(t, "units", target)
require.Equal(t, "AB1", district)
// Simulate a successful retrieval of a feature collection
fc := geojson.NewFeatureCollection()
feature := geojson.NewFeature(nil)
feature.ID = "AB1 2CD"
fc.Append(feature)
return fc, nil
},
}

handler := PolygonSearch(spatialIdx, repo)
handler(c)

require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "AB1 2CD")
}

func TestPolygonSearch_PolygonNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/polygon?bbox=0,0,1,1", nil)
c.Request.URL.RawQuery = "bbox=0,0,1,1"

spatialIdx := &mockSpatialIndex{
SearchIterFunc: func(bounds []uint32, iter func([2]uint32, [2]uint32, string) bool) error {
iter([2]uint32{0, 0}, [2]uint32{1, 1}, "AB1 2CD")
return nil
},
}

repo := &mockPolygonsRepo{
RetrieveFeatureCollectionFunc: func(target string, district string) (*geojson.FeatureCollection, error) {
return nil, os.ErrNotExist
},
}

handler := PolygonSearch(spatialIdx, repo)
handler(c)

require.Equal(t, http.StatusOK, w.Code)
require.NotContains(t, w.Body.String(), "AB1 2CD")
}

func TestPolygonSearch_PolygonRepoError(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/polygon?bbox=0,0,1,1", nil)
c.Request.URL.RawQuery = "bbox=0,0,1,1"

spatialIdx := &mockSpatialIndex{
SearchIterFunc: func(bounds []uint32, iter func([2]uint32, [2]uint32, string) bool) error {
iter([2]uint32{0, 0}, [2]uint32{1, 1}, "AB1 2CD")
return nil
},
}

repo := &mockPolygonsRepo{
RetrieveFeatureCollectionFunc: func(target string, district string) (*geojson.FeatureCollection, error) {
return nil, errors.New("failed to load polygon")
},
}

handler := PolygonSearch(spatialIdx, repo)
handler(c)

require.Equal(t, http.StatusInternalServerError, w.Code)
Expand Down