Skip to content

Commit

Permalink
Merge pull request #227 from PDOK/geodatatiles-fix
Browse files Browse the repository at this point in the history
fix(tiles): fix proxying of collection-level tiles
  • Loading branch information
rkettelerij authored Sep 6, 2024
2 parents dbb64d1 + 580f58a commit d85483b
Show file tree
Hide file tree
Showing 6 changed files with 159 additions and 32 deletions.
7 changes: 7 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ func validate(config *Config) error {
if config.OgcAPI.Features != nil {
return validateFeatureCollections(config.OgcAPI.Features.Collections)
}
if config.OgcAPI.Tiles != nil && len(config.OgcAPI.Tiles.Collections) > 0 {
for _, coll := range config.OgcAPI.Tiles.Collections {
if coll.Tiles == nil {
return errors.New("invalid tiles config provided: no tileserver(s) configured for collection-level tiles")
}
}
}
return nil
}

Expand Down
8 changes: 8 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ func TestNewConfig(t *testing.T) {
wantErr: true,
wantErrMsg: "validation for 'Version' failed on the 'semver' tag",
},
{
name: "fail on invalid config file for geodata tiles (collection-level tiles)",
args: args{
configFile: "internal/engine/testdata/config_invalid_geodatatiles.yaml",
},
wantErr: true,
wantErrMsg: "invalid tiles config provided: no tileserver(s) configured for collection-level tiles",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
6 changes: 6 additions & 0 deletions internal/engine/problems.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,9 @@ func RenderProblem(kind ProblemKind, w http.ResponseWriter, details ...string) {
log.Printf("failed to write response: %v", err)
}
}

// RenderProblemAndLog writes RFC 7807 (https://tools.ietf.org/html/rfc7807) problem to client + logs message to stdout.
func RenderProblemAndLog(kind ProblemKind, w http.ResponseWriter, err error, details ...string) {
log.Printf("%v", err.Error())
RenderProblem(kind, w, details...)
}
27 changes: 27 additions & 0 deletions internal/engine/testdata/config_invalid_geodatatiles.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
version: 2.0.0
title: 'Foobar'
serviceIdentifier: FB
abstract: foo bar baz
license:
name: CC0 1.0
url: http://creativecommons.org/publicdomain/zero/1.0/deed.nl
baseUrl: http://localhost:8080
availableLanguages:
- en
ogcApi:
tiles:
# collection-level tiles without tileserver config is invalid
collections:
- id: foo
metadata:
title: Foo
- id: bar
metadata:
title: Bar
- id: baz
metadata:
title: Baz
keywords:
- foo
- bar
105 changes: 78 additions & 27 deletions internal/ogc/tiles/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package tiles

import (
"log"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -90,6 +91,7 @@ func NewTiles(e *engine.Engine) *Tiles {
}

// Collection-level tiles (geodata tiles in OGC spec)
geoDataTiles := map[string]config.Tiles{}
for _, coll := range e.Config.OgcAPI.Tiles.Collections {
if coll.Tiles == nil {
continue
Expand All @@ -99,11 +101,15 @@ func NewTiles(e *engine.Engine) *Tiles {
e.Config.BaseURL.String() + g.CollectionsPath + "/" + coll.ID,
util.Cast(AllProjections),
})
geoDataTiles[coll.ID] = coll.Tiles.GeoDataTiles
}
if len(geoDataTiles) != 0 {
e.Router.Get(g.CollectionsPath+"/{collectionId}"+tilesPath, tiles.TilesetsListForCollection())
e.Router.Get(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}", tiles.TilesetForCollection())
e.Router.Head(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}", tiles.Tile(coll.Tiles.GeoDataTiles))
e.Router.Get(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}", tiles.Tile(coll.Tiles.GeoDataTiles))
e.Router.Head(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}", tiles.TileForCollection(geoDataTiles))
e.Router.Get(g.CollectionsPath+"/{collectionId}"+tilesPath+"/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}", tiles.TileForCollection(geoDataTiles))
}

return tiles
}

Expand Down Expand Up @@ -154,44 +160,90 @@ func (t *Tiles) TilesetForCollection() http.HandlerFunc {
}
}

// Tile reverse proxy to configured tileserver/object storage. Assumes the backing resources is publicly accessible.
func (t *Tiles) Tile(tileConfig config.Tiles) http.HandlerFunc {
// Tile reverse proxy to configured tileserver/object storage. Assumes the backing resource is publicly accessible.
func (t *Tiles) Tile(tilesConfig config.Tiles) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tileMatrixSetID := chi.URLParam(r, "tileMatrixSetId")
tileMatrix := chi.URLParam(r, "tileMatrix")
tileRow := chi.URLParam(r, "tileRow")
tileCol := chi.URLParam(r, "tileCol")

// We support content negotiation using Accept header and ?f= param, but also
// using the .pbf extension. This is for backwards compatibility.
if !strings.HasSuffix(tileCol, "."+engine.FormatMVTAlternative) {
// if no format is specified, default to mvt
if format := strings.Replace(t.engine.CN.NegotiateFormat(r), engine.FormatJSON, engine.FormatMVT, 1); format != engine.FormatMVT && format != engine.FormatMVTAlternative {
engine.RenderProblem(engine.ProblemBadRequest, w, "Specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported")
return
}
} else {
tileCol = tileCol[:len(tileCol)-4] // remove .pbf extension
tileCol, err := getTileColumn(r, t.engine.CN.NegotiateFormat(r))
if err != nil {
engine.RenderProblemAndLog(engine.ProblemBadRequest, w, err, err.Error())
return
}

// ogc spec is (default) z/row/col but tileserver is z/col/row (z/x/y)
replacer := strings.NewReplacer("{tms}", tileMatrixSetID, "{z}", tileMatrix, "{x}", tileCol, "{y}", tileRow)
tilesTmpl := defaultTilesTmpl
if tileConfig.URITemplateTiles != nil {
tilesTmpl = *tileConfig.URITemplateTiles
target, err := createTilesURL(tileMatrixSetID, tileMatrix, tileCol, tileRow, tilesConfig)
if err != nil {
engine.RenderProblemAndLog(engine.ProblemServerError, w, err)
return
}
path, _ := url.JoinPath("/", replacer.Replace(tilesTmpl))
t.engine.ReverseProxy(w, r, target, true, engine.MediaTypeMVT)
}
}

target, err := url.Parse(tileConfig.TileServer.String() + path)
// TileForCollection reverse proxy to configured tileserver/object storage for tiles within a given collection.
// Assumes the backing resource is publicly accessible.
func (t *Tiles) TileForCollection(tilesConfigByCollection map[string]config.Tiles) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "collectionId")
tileMatrixSetID := chi.URLParam(r, "tileMatrixSetId")
tileMatrix := chi.URLParam(r, "tileMatrix")
tileRow := chi.URLParam(r, "tileRow")
tileCol, err := getTileColumn(r, t.engine.CN.NegotiateFormat(r))
if err != nil {
log.Printf("invalid target url, can't proxy tiles: %v", err)
engine.RenderProblem(engine.ProblemServerError, w)
engine.RenderProblemAndLog(engine.ProblemBadRequest, w, err, err.Error())
return
}

tilesConfig, ok := tilesConfigByCollection[collectionID]
if !ok {
err = fmt.Errorf("no tiles available for collection: %s", collectionID)
engine.RenderProblemAndLog(engine.ProblemNotFound, w, err, err.Error())
return
}
target, err := createTilesURL(tileMatrixSetID, tileMatrix, tileCol, tileRow, tilesConfig)
if err != nil {
engine.RenderProblemAndLog(engine.ProblemServerError, w, err)
return
}
t.engine.ReverseProxy(w, r, target, true, engine.MediaTypeMVT)
}
}

func getTileColumn(r *http.Request, format string) (string, error) {
tileCol := chi.URLParam(r, "tileCol")

// We support content negotiation using Accept header and ?f= param, but also
// using the .pbf extension. This is for backwards compatibility.
if !strings.HasSuffix(tileCol, "."+engine.FormatMVTAlternative) {
// if no format is specified, default to mvt
if f := strings.Replace(format, engine.FormatJSON, engine.FormatMVT, 1); f != engine.FormatMVT && f != engine.FormatMVTAlternative {
return "", errors.New("specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported")
}
} else {
tileCol = tileCol[:len(tileCol)-4] // remove .pbf extension
}
return tileCol, nil
}

func createTilesURL(tileMatrixSetID string, tileMatrix string, tileCol string,
tileRow string, tilesCfg config.Tiles) (*url.URL, error) {

tilesTmpl := defaultTilesTmpl
if tilesCfg.URITemplateTiles != nil {
tilesTmpl = *tilesCfg.URITemplateTiles
}
// OGC spec is (default) z/row/col but tileserver is z/col/row (z/x/y)
replacer := strings.NewReplacer("{tms}", tileMatrixSetID, "{z}", tileMatrix, "{x}", tileCol, "{y}", tileRow)
path, _ := url.JoinPath("/", replacer.Replace(tilesTmpl))

target, err := url.Parse(tilesCfg.TileServer.String() + path)
if err != nil {
return nil, fmt.Errorf("invalid target url, can't proxy tiles: %w", err)
}
return target, nil
}

func renderTileMatrixTemplates(e *engine.Engine) {
e.RenderTemplates(tileMatrixSetsPath,
tileMatrixSetsBreadcrumbs,
Expand All @@ -215,7 +267,6 @@ func renderTileMatrixTemplates(e *engine.Engine) {
}

func renderTilesTemplates(e *engine.Engine, collection *config.GeoSpatialCollection, data templateData) {

var breadcrumbs []engine.Breadcrumb
path := tilesPath
collectionID := ""
Expand Down
38 changes: 33 additions & 5 deletions internal/ogc/tiles/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ func TestTiles_Tile(t *testing.T) {
tileCol: "15",
},
want: want{
body: "Specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported",
body: "specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported",
statusCode: http.StatusBadRequest,
},
},
Expand Down Expand Up @@ -269,6 +269,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix string
tileRow string
tileCol string
collection string
}
type want struct {
body string
Expand All @@ -288,6 +289,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "0",
tileRow: "0",
tileCol: "0",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/0/0/0.pbf",
Expand All @@ -303,6 +305,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/5/15/10.pbf",
Expand All @@ -318,6 +321,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/5/15/10.pbf",
Expand All @@ -333,6 +337,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/5/15/10.pbf",
Expand All @@ -348,6 +353,7 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15.pbf",
collection: "example",
},
want: want{
body: "/NetherlandsRDNewQuad/5/15/10.pbf",
Expand All @@ -363,16 +369,33 @@ func TestTiles_TileForCollection(t *testing.T) {
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "example",
},
want: want{
body: "Specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported",
body: "specify tile format. Currently only Mapbox Vector Tiles (?f=mvt) tiles are supported",
statusCode: http.StatusBadRequest,
},
},
{
name: "invalid/NetherlandsRDNewQuad/5/10/15?=pbf",
fields: fields{
configFile: "internal/ogc/tiles/testdata/config_tiles_collectionlevel.yaml",
url: "http://localhost:8080/invalid/tiles/:tileMatrixSetId/:tileMatrix/:tileRow/:tileCol?f=pbf",
tileMatrixSetID: "NetherlandsRDNewQuad",
tileMatrix: "5",
tileRow: "10",
tileCol: "15",
collection: "invalid",
},
want: want{
body: "no tiles available for collection: invalid",
statusCode: http.StatusNotFound,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := createTileRequest(tt.fields.url, tt.fields.tileMatrixSetID, tt.fields.tileMatrix, tt.fields.tileRow, tt.fields.tileCol)
req, err := createTileRequest(tt.fields.url, tt.fields.tileMatrixSetID, tt.fields.tileMatrix, tt.fields.tileRow, tt.fields.tileCol, tt.fields.collection)
if err != nil {
log.Fatal(err)
}
Expand All @@ -382,7 +405,8 @@ func TestTiles_TileForCollection(t *testing.T) {
newEngine, err := engine.NewEngine(tt.fields.configFile, "", false, true)
assert.NoError(t, err)
tiles := NewTiles(newEngine)
handler := tiles.Tile(newEngine.Config.OgcAPI.Tiles.Collections[0].Tiles.GeoDataTiles)
geoDataTiles := map[string]config.Tiles{newEngine.Config.OgcAPI.Tiles.Collections[0].ID: newEngine.Config.OgcAPI.Tiles.Collections[0].Tiles.GeoDataTiles}
handler := tiles.TileForCollection(geoDataTiles)
handler.ServeHTTP(rr, req)

assert.Equal(t, tt.want.statusCode, rr.Code)
Expand Down Expand Up @@ -842,9 +866,13 @@ func createMockServer() (*httptest.ResponseRecorder, *httptest.Server) {
return rr, ts
}

func createTileRequest(url string, tileMatrixSetID string, tileMatrix string, tileRow string, tileCol string) (*http.Request, error) {
func createTileRequest(url string, tileMatrixSetID string, tileMatrix string, tileRow string, tileCol string, collectionID ...string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
rctx := chi.NewRouteContext()
for _, id := range collectionID {
rctx.URLParams.Add("collectionId", id)
}

rctx.URLParams.Add("tileMatrixSetId", tileMatrixSetID)
rctx.URLParams.Add("tileMatrix", tileMatrix)
rctx.URLParams.Add("tileRow", tileRow)
Expand Down

0 comments on commit d85483b

Please sign in to comment.