diff --git a/cmd/api_server.go b/cmd/api_server.go index 4ef8e67..e79fbd8 100644 --- a/cmd/api_server.go +++ b/cmd/api_server.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "postcode-polygons/internal" "postcode-polygons/routes" spatialindex "postcode-polygons/spatial-index" "time" @@ -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) diff --git a/internal/polygons_repo.go b/internal/polygons_repo.go new file mode 100644 index 0000000..5e960ea --- /dev/null +++ b/internal/polygons_repo.go @@ -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) + featureCollection, err, _ := memoize.Call(cp.cache, filename, func() (*geojson.FeatureCollection, error) { + return DecompressFeatureCollection(filename) + }) + return featureCollection, err +} diff --git a/routes/search.go b/routes/search.go index ef01027..0ef1b29 100644 --- a/routes/search.go +++ b/routes/search.go @@ -13,7 +13,6 @@ import ( "os" "github.com/gin-gonic/gin" - "github.com/kofalt/go-memoize" "github.com/paulmach/orb/geojson" ) @@ -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 { @@ -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 } diff --git a/routes/search_test.go b/routes/search_test.go index 4068d99..d0ee626 100644 --- a/routes/search_test.go +++ b/routes/search_test.go @@ -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" ) @@ -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() @@ -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) @@ -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)